feat(F-103): completed feature
This commit is contained in:
@@ -4530,6 +4530,45 @@
|
|||||||
"security": false,
|
"security": false,
|
||||||
"qa": 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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ interface FormState {
|
|||||||
isParent: boolean;
|
isParent: boolean;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
textColor: string;
|
||||||
seoTitle: string;
|
seoTitle: string;
|
||||||
seoTitleManual: boolean;
|
seoTitleManual: boolean;
|
||||||
seoDescription: string;
|
seoDescription: string;
|
||||||
@@ -80,12 +82,31 @@ const EMPTY_FORM: FormState = {
|
|||||||
isParent: false,
|
isParent: false,
|
||||||
emoji: '',
|
emoji: '',
|
||||||
color: '',
|
color: '',
|
||||||
|
bgColor: '',
|
||||||
|
textColor: '',
|
||||||
seoTitle: '',
|
seoTitle: '',
|
||||||
seoTitleManual: false,
|
seoTitleManual: false,
|
||||||
seoDescription: '',
|
seoDescription: '',
|
||||||
seoDescManual: false,
|
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() {
|
export default function CategoriesPage() {
|
||||||
const [tree, setTree] = useState<Category[]>([]);
|
const [tree, setTree] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -95,6 +116,7 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -150,6 +172,8 @@ export default function CategoriesPage() {
|
|||||||
isParent: cat.isParent ?? false,
|
isParent: cat.isParent ?? false,
|
||||||
emoji: cat.emoji ?? '',
|
emoji: cat.emoji ?? '',
|
||||||
color: cat.color ?? '',
|
color: cat.color ?? '',
|
||||||
|
bgColor: cat.bgColor ?? '',
|
||||||
|
textColor: cat.textColor ?? '',
|
||||||
seoTitle: (cat as any).seoTitle ?? '',
|
seoTitle: (cat as any).seoTitle ?? '',
|
||||||
seoTitleManual: true,
|
seoTitleManual: true,
|
||||||
seoDescription: (cat as any).seoDescription ?? '',
|
seoDescription: (cat as any).seoDescription ?? '',
|
||||||
@@ -169,7 +193,7 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true); setMsg('');
|
setSaving(true); setMsg(''); setGenerating(false);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -179,15 +203,32 @@ export default function CategoriesPage() {
|
|||||||
isParent: form.isParent,
|
isParent: form.isParent,
|
||||||
emoji: form.emoji || undefined,
|
emoji: form.emoji || undefined,
|
||||||
color: form.color || undefined,
|
color: form.color || undefined,
|
||||||
|
bgColor: form.bgColor || undefined,
|
||||||
|
textColor: form.textColor || undefined,
|
||||||
seoTitle: form.seoTitle || undefined,
|
seoTitle: form.seoTitle || undefined,
|
||||||
seoDescription: form.seoDescription || undefined,
|
seoDescription: form.seoDescription || undefined,
|
||||||
};
|
};
|
||||||
|
let saved: Category;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await categoriesApi.update(editing.id, payload);
|
saved = await categoriesApi.update(editing.id, payload);
|
||||||
setMsg('Categoría actualizada');
|
|
||||||
} else {
|
} else {
|
||||||
await categoriesApi.create(payload);
|
saved = await categoriesApi.create(payload);
|
||||||
setMsg('Categoría creada');
|
}
|
||||||
|
// 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);
|
setShowForm(false);
|
||||||
load();
|
load();
|
||||||
@@ -221,7 +262,16 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg && (
|
{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 && (
|
{showForm && (
|
||||||
@@ -293,37 +343,92 @@ export default function CategoriesPage() {
|
|||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
rows={2}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Emoji y color de la tarjeta */}
|
{/* Emoji y color de la tarjeta */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Emoji</label>
|
<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
|
<input
|
||||||
value={form.emoji}
|
value={form.emoji}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value }))}
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
placeholder="🥜"
|
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>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
|
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
|
||||||
<input
|
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
||||||
value={form.color}
|
<div className="flex flex-wrap gap-1.5">
|
||||||
onChange={(e) => setForm((f) => ({ ...f, color: e.target.value }))}
|
{CARD_PRESETS.map((preset) => (
|
||||||
maxLength={200}
|
<button key={preset.label} type="button" title={preset.label}
|
||||||
placeholder="bg-[#70ad47]/10 text-[#70ad47] ó from-[#70ad47] to-[#40916C]"
|
onClick={() => setForm((f) => ({ ...f, bgColor: preset.bg, textColor: preset.text }))}
|
||||||
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"
|
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'
|
||||||
<p className="text-xs text-gray-400 mt-1">Clase Tailwind para el color de la tarjeta (fondo o gradiente).</p>
|
}`}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,10 +473,10 @@ export default function CategoriesPage() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(false)}
|
onClick={() => setShowForm(false)}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ export default function ServerLogsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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} />
|
<ServerLogViewer backendUrl={backendUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('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('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('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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
|
|||||||
const json = raw.slice(6);
|
const json = raw.slice(6);
|
||||||
setLogs(prev => {
|
setLogs(prev => {
|
||||||
const next = [...prev, parseLine(json)];
|
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"
|
className="flex-1 overflow-y-auto bg-[#0d1117] font-mono text-xs leading-relaxed"
|
||||||
style={{ minHeight: 0 }}
|
style={{ minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<table className="w-full">
|
<table className="w-full table-fixed">
|
||||||
<tbody>
|
<tbody>
|
||||||
{logs.map((entry, i) => (
|
{logs.map((entry, i) => (
|
||||||
<tr
|
<tr
|
||||||
|
|||||||
@@ -51,13 +51,13 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
|||||||
const isCreate = !productId;
|
const isCreate = !productId;
|
||||||
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
const [loading, setLoading] = useState(!isCreate);
|
const [loading, setLoading] = useState(!isCreate);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [slugManual, setSlugManual] = useState(false);
|
|
||||||
const [desc, setDesc] = useState('');
|
const [desc, setDesc] = useState('');
|
||||||
const [brandId, setBrandId] = useState('');
|
const [brandId, setBrandId] = useState('');
|
||||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||||
@@ -140,7 +140,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
|||||||
|
|
||||||
const handleNameChange = (v: string) => {
|
const handleNameChange = (v: string) => {
|
||||||
setName(v);
|
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 (!seoTitleManual) setSeoTitle(v);
|
||||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en mercadodevida. Productos naturales y ecológicos.`);
|
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);
|
if (isCreate) saved = await productsApi.create(payload);
|
||||||
else saved = await productsApi.update(productId, payload);
|
else saved = await productsApi.update(productId, payload);
|
||||||
if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
|
if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
|
||||||
|
setGenerating(true);
|
||||||
|
setSuccess('✨ Generando contenido con IA… esto puede tardar unos segundos.');
|
||||||
try {
|
try {
|
||||||
saved = await productsApi.generateSeo(saved.id);
|
saved = await productsApi.generateSeo(saved.id);
|
||||||
setDesc(saved.description ?? '');
|
setDesc(saved.description ?? '');
|
||||||
setSeoTitle(saved.seoTitle ?? '');
|
setSeoTitle(saved.seoTitle ?? '');
|
||||||
setSeoDesc(saved.seoDescription ?? '');
|
setSeoDesc(saved.seoDescription ?? '');
|
||||||
|
setSuccess(isCreate ? '¡Producto creado! Contenido generado con IA.' : 'Cambios guardados. Contenido generado con IA.');
|
||||||
} catch (generationError) {
|
} catch (generationError) {
|
||||||
setError(generationError instanceof Error ? generationError.message : 'No se pudieron generar los campos SEO');
|
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();
|
snapRef.current = getSnap();
|
||||||
dirtyRef.current = false;
|
dirtyRef.current = false;
|
||||||
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
|
|
||||||
if (isCreate) router.push(`/products/${saved.id}`);
|
if (isCreate) router.push(`/products/${saved.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
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>
|
<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>
|
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
|
||||||
</div>
|
</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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</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 */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
|
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
|
||||||
@@ -238,22 +250,28 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
|||||||
{/* ── GENERAL ── */}
|
{/* ── GENERAL ── */}
|
||||||
{tab === 'general' && (
|
{tab === 'general' && (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
|
<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 text-[#2D6A4F] font-medium">auto-generado</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
|
<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
|
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
|
||||||
placeholder="Ej: Almendras Crudas Ecológicas"
|
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" />
|
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>
|
<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">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
|
||||||
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
|
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||||
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
<span className="text-sm font-semibold text-gray-900 whitespace-nowrap">⭐ Producto destacado</span>
|
||||||
{slugManual ? 'editado manualmente' : 'auto-generado'}
|
<span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
|
||||||
</span>
|
</label>
|
||||||
</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" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
|
<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…"
|
placeholder="Descripción detallada del producto…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
|
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
|
||||||
<select value={brandId} onChange={e => setBrandId(e.target.value)}
|
<select value={brandId} onChange={e => setBrandId(e.target.value)}
|
||||||
@@ -279,12 +297,10 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
|||||||
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<label className="block text-sm font-semibold text-gray-900 mb-1.5">
|
||||||
<label className="text-sm font-semibold text-gray-900">Fecha de caducidad</label>
|
Fecha de caducidad <span className="text-xs text-gray-400 font-normal">(opcional)</span>
|
||||||
<span className="text-xs text-gray-400">opcional</span>
|
</label>
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={expirationDate}
|
value={expirationDate}
|
||||||
@@ -293,13 +309,6 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
|
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ export const ordersApi = {
|
|||||||
if (params?.status) sp.set('status', params.status);
|
if (params?.status) sp.set('status', params.status);
|
||||||
if (params?.q) sp.set('q', params.q);
|
if (params?.q) sp.set('q', params.q);
|
||||||
const qs = sp.toString();
|
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}` : ''}`,
|
`/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}`),
|
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
|
||||||
transition: (id: string, state: string) =>
|
transition: (id: string, state: string) =>
|
||||||
@@ -156,6 +156,8 @@ export const categoriesApi = {
|
|||||||
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
|
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
|
||||||
update: (id: string, data: unknown) =>
|
update: (id: string, data: unknown) =>
|
||||||
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
|
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}`),
|
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,6 +339,9 @@ export interface StoreSettings {
|
|||||||
aiSeoTitlePrompt: string;
|
aiSeoTitlePrompt: string;
|
||||||
aiSeoDescriptionPrompt: string;
|
aiSeoDescriptionPrompt: string;
|
||||||
aiProductDescriptionPrompt: string;
|
aiProductDescriptionPrompt: string;
|
||||||
|
aiCategoryDescriptionPrompt: string;
|
||||||
|
aiCategorySeoTitlePrompt: string;
|
||||||
|
aiCategorySeoDescriptionPrompt: string;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpPort: string;
|
smtpPort: string;
|
||||||
smtpSecure: boolean;
|
smtpSecure: boolean;
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ export interface Category {
|
|||||||
isParent?: boolean;
|
isParent?: boolean;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
textColor?: string;
|
||||||
children?: Category[];
|
children?: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
|
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -92,8 +93,8 @@ export default async function BrandPage({ params }: Props) {
|
|||||||
</h3>
|
</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div
|
<div
|
||||||
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
|
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pr-2">
|
<div className="mt-3 pr-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
|
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -13,7 +14,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
if (!category) return { title: 'Categoría no encontrada' };
|
if (!category) return { title: 'Categoría no encontrada' };
|
||||||
return {
|
return {
|
||||||
title: category.seoTitle ?? category.name,
|
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)' }}>
|
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</h1>
|
</h1>
|
||||||
{category.seoDescription && (
|
{(category.description || category.seoDescription) && (
|
||||||
<p className="mt-2 text-gray-600">{category.seoDescription}</p>
|
<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>
|
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,8 +106,8 @@ export default async function CategoryPage({ params }: Props) {
|
|||||||
</h3>
|
</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div
|
<div
|
||||||
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
|
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pr-2">
|
<div className="mt-3 pr-2">
|
||||||
|
|||||||
@@ -39,14 +39,18 @@ export default async function CategoriesPage() {
|
|||||||
{tree.map((cat, i) => {
|
{tree.map((cat, i) => {
|
||||||
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
|
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
|
||||||
const colorClass = cat.color ?? colors[i % colors.length];
|
const colorClass = cat.color ?? colors[i % colors.length];
|
||||||
|
const customColors = Boolean(cat.bgColor || cat.textColor);
|
||||||
return (
|
return (
|
||||||
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
|
<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 className="absolute top-4 right-4 text-5xl opacity-20">{emoji}</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
|
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
|
||||||
{cat.seoDescription && (
|
{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>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -33,3 +33,29 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans), system-ui, sans-serif;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import ProductAddToCart from '@/components/cart/ProductAddToCart';
|
import ProductAddToCart from '@/components/cart/ProductAddToCart';
|
||||||
import ProductAttributes from '@/components/product/ProductAttributes';
|
import ProductAttributes from '@/components/product/ProductAttributes';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -199,8 +200,8 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
|
||||||
<div
|
<div
|
||||||
className="text-gray-600 leading-relaxed prose prose-sm max-w-none"
|
className="text-gray-600 leading-relaxed rich-text prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Productos — mercadodevida',
|
title: 'Productos — mercadodevida',
|
||||||
@@ -73,8 +74,8 @@ export default async function ProductsPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div
|
<div
|
||||||
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
|
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pr-2">
|
<div className="mt-3 pr-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
|
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
|
||||||
@@ -108,8 +109,8 @@ export default async function SearchPage({ searchParams }: Props) {
|
|||||||
</h3>
|
</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div
|
<div
|
||||||
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
|
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pr-2">
|
<div className="mt-3 pr-2">
|
||||||
|
|||||||
@@ -43,11 +43,15 @@ export default async function CategoriesGrid() {
|
|||||||
const totalProducts = cat.children ? cat.children.length * 5 : 5;
|
const totalProducts = cat.children ? cat.children.length * 5 : 5;
|
||||||
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
|
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
|
||||||
const colorClass = cat.color ?? colors[i % colors.length];
|
const colorClass = cat.color ?? colors[i % colors.length];
|
||||||
|
const customColors = Boolean(cat.bgColor || cat.textColor);
|
||||||
return (
|
return (
|
||||||
<Link key={cat.id} href={`/categories/${cat.slug}`}>
|
<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>
|
<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>
|
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
|
||||||
{cat.children && cat.children.length > 0 && (
|
{cat.children && cat.children.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Product } from '@/types/api';
|
import type { Product } from '@/types/api';
|
||||||
import { fetchProducts } from '@/lib/api';
|
import { fetchProducts } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
|
|
||||||
function formatPrice(cents: number): string {
|
function formatPrice(cents: number): string {
|
||||||
return `€${(cents / 100).toFixed(2)}`;
|
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>
|
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<div
|
<div
|
||||||
className="text-gray-500 text-sm mt-1 line-clamp-2 prose prose-xs max-w-none"
|
className="text-gray-500 text-sm mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 pr-2">
|
<div className="mt-3 pr-2">
|
||||||
|
|||||||
44
project/frontend/src/lib/format-rich-text.ts
Normal file
44
project/frontend/src/lib/format-rich-text.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ export interface Category {
|
|||||||
isParent?: boolean;
|
isParent?: boolean;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
textColor?: string;
|
||||||
children?: Category[];
|
children?: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
project/migrations/034_category_description_colors.js
Normal file
23
project/migrations/034_category_description_colors.js
Normal 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
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
|||||||
import { AppError } from '../../../shared/errors.js';
|
import { AppError } from '../../../shared/errors.js';
|
||||||
import { parseJson } from '../../../shared/http-input.js';
|
import { parseJson } from '../../../shared/http-input.js';
|
||||||
import { errorSchema } from '../../../shared/swagger.js';
|
import { errorSchema } from '../../../shared/swagger.js';
|
||||||
|
import { aiTextToHtml, generateWithModel } from '../../../shared/ai-content.js';
|
||||||
import {
|
import {
|
||||||
AttachProductImage,
|
AttachProductImage,
|
||||||
DetachProductImage,
|
DetachProductImage,
|
||||||
@@ -285,13 +286,14 @@ export async function registerCatalogRoutes(
|
|||||||
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
|
(template || fallback).replace(/\{\{(name|description|brand)\}\}/g, (_, key: string) => replacements[key] ?? '');
|
||||||
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
|
const patch: { description?: string; seoTitle?: string; seoDescription?: string } = {};
|
||||||
if (!product.description?.trim()) {
|
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()) {
|
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()) {
|
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);
|
const updated = await repository.update(id, patch);
|
||||||
return reply.send(serializeProduct(updated ?? product));
|
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');
|
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 {
|
function sanitizeSearchTelemetryQuery(query: string | undefined): string | undefined {
|
||||||
if (query === undefined) {
|
if (query === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
CategoryTreeCycleError,
|
CategoryTreeCycleError,
|
||||||
} from '../domain/errors.js';
|
} from '../domain/errors.js';
|
||||||
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
|
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
|
||||||
|
import { generateWithModel, loadAiSettings, requireAiConfigured } from '../../../shared/ai-content.js';
|
||||||
|
|
||||||
export interface CategoriesRoutesDeps {
|
export interface CategoriesRoutesDeps {
|
||||||
pool: pg.Pool;
|
pool: pg.Pool;
|
||||||
@@ -41,9 +42,12 @@ const newCategorySchema = z.object({
|
|||||||
slug: slugSchema,
|
slug: slugSchema,
|
||||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||||
|
description: z.string().min(1).max(4000).optional().nullable(),
|
||||||
isParent: z.boolean().optional(),
|
isParent: z.boolean().optional(),
|
||||||
emoji: z.string().max(10).optional().nullable(),
|
emoji: z.string().max(10).optional().nullable(),
|
||||||
color: z.string().max(200).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
|
const categoryPatchSchema = newCategorySchema
|
||||||
@@ -156,6 +160,81 @@ export async function registerCategoriesRoutes(
|
|||||||
}
|
}
|
||||||
return reply.code(204).send();
|
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 {
|
function mapCategoryError(error: unknown): Error {
|
||||||
@@ -183,9 +262,12 @@ function serializeCategory(category: Category) {
|
|||||||
url: `/categoria/${category.slug}`,
|
url: `/categoria/${category.slug}`,
|
||||||
seoTitle: category.seoTitle,
|
seoTitle: category.seoTitle,
|
||||||
seoDescription: category.seoDescription,
|
seoDescription: category.seoDescription,
|
||||||
|
description: category.description,
|
||||||
isParent: category.isParent,
|
isParent: category.isParent,
|
||||||
emoji: category.emoji,
|
emoji: category.emoji,
|
||||||
color: category.color,
|
color: category.color,
|
||||||
|
bgColor: category.bgColor,
|
||||||
|
textColor: category.textColor,
|
||||||
createdAt: category.createdAt.toISOString(),
|
createdAt: category.createdAt.toISOString(),
|
||||||
updatedAt: category.updatedAt.toISOString(),
|
updatedAt: category.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ export interface Category {
|
|||||||
slug: string;
|
slug: string;
|
||||||
seoTitle: string | null;
|
seoTitle: string | null;
|
||||||
seoDescription: string | null;
|
seoDescription: string | null;
|
||||||
|
description?: string | null;
|
||||||
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
||||||
isParent: boolean;
|
isParent: boolean;
|
||||||
emoji?: string | null;
|
emoji?: string | null;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
bgColor?: string | null;
|
||||||
|
textColor?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -22,9 +25,12 @@ export interface NewCategory {
|
|||||||
slug: string;
|
slug: string;
|
||||||
seoTitle?: string | null;
|
seoTitle?: string | null;
|
||||||
seoDescription?: string | null;
|
seoDescription?: string | null;
|
||||||
|
description?: string | null;
|
||||||
isParent?: boolean;
|
isParent?: boolean;
|
||||||
emoji?: string | null;
|
emoji?: string | null;
|
||||||
color?: 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. */
|
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ interface CategoryRow {
|
|||||||
slug: string;
|
slug: string;
|
||||||
seo_title: string | null;
|
seo_title: string | null;
|
||||||
seo_description: string | null;
|
seo_description: string | null;
|
||||||
|
description: string | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
bg_color: string | null;
|
||||||
|
text_color: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -25,9 +28,12 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
|||||||
['slug', 'slug'],
|
['slug', 'slug'],
|
||||||
['seoTitle', 'seo_title'],
|
['seoTitle', 'seo_title'],
|
||||||
['seoDescription', 'seo_description'],
|
['seoDescription', 'seo_description'],
|
||||||
|
['description', 'description'],
|
||||||
['isParent', 'is_parent'],
|
['isParent', 'is_parent'],
|
||||||
['emoji', 'emoji'],
|
['emoji', 'emoji'],
|
||||||
['color', 'color'],
|
['color', 'color'],
|
||||||
|
['bgColor', 'bg_color'],
|
||||||
|
['textColor', 'text_color'],
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PgCategoryRepository implements CategoryRepository {
|
export class PgCategoryRepository implements CategoryRepository {
|
||||||
@@ -61,8 +67,8 @@ export class PgCategoryRepository implements CategoryRepository {
|
|||||||
async create(input: NewCategory): Promise<Category> {
|
async create(input: NewCategory): Promise<Category> {
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query<CategoryRow>(
|
const result = await this.pool.query<CategoryRow>(
|
||||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent, emoji, color)
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
input.parentId ?? null,
|
input.parentId ?? null,
|
||||||
@@ -70,9 +76,12 @@ export class PgCategoryRepository implements CategoryRepository {
|
|||||||
input.slug,
|
input.slug,
|
||||||
input.seoTitle ?? null,
|
input.seoTitle ?? null,
|
||||||
input.seoDescription ?? null,
|
input.seoDescription ?? null,
|
||||||
|
input.description ?? null,
|
||||||
input.isParent ?? false,
|
input.isParent ?? false,
|
||||||
input.emoji ?? null,
|
input.emoji ?? null,
|
||||||
input.color ?? null,
|
input.color ?? null,
|
||||||
|
input.bgColor ?? null,
|
||||||
|
input.textColor ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
@@ -155,9 +164,12 @@ function toCategory(row: CategoryRow): Category {
|
|||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
seoTitle: row.seo_title,
|
seoTitle: row.seo_title,
|
||||||
seoDescription: row.seo_description,
|
seoDescription: row.seo_description,
|
||||||
|
description: row.description,
|
||||||
isParent: row.is_parent,
|
isParent: row.is_parent,
|
||||||
emoji: row.emoji,
|
emoji: row.emoji,
|
||||||
color: row.color,
|
color: row.color,
|
||||||
|
bgColor: row.bg_color,
|
||||||
|
textColor: row.text_color,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -153,13 +153,30 @@ export async function registerOrdersRoutes(
|
|||||||
const listOrdersSchema: FastifySchema = {
|
const listOrdersSchema: FastifySchema = {
|
||||||
tags: ['Orders'],
|
tags: ['Orders'],
|
||||||
summary: 'List orders (admin)',
|
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 },
|
response: { 401: errorSchema, 403: errorSchema },
|
||||||
};
|
};
|
||||||
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
|
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
|
||||||
const user = await deps.authenticate(request);
|
const user = await deps.authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const orders = await service.listOrders();
|
const query = request.query as { status?: string; q?: string; limit?: string; offset?: string };
|
||||||
return reply.send(orders.map(serializeOrder));
|
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 = {
|
const getOrderAdminSchema: FastifySchema = {
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export class OrderService implements OrderServicePort {
|
|||||||
return this.repo.findAll();
|
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> {
|
async transition(id: string, next: OrderState, userId: string): Promise<OrderView> {
|
||||||
const existing = await this.repo.findByIdAndUserId(id, userId);
|
const existing = await this.repo.findByIdAndUserId(id, userId);
|
||||||
if (!existing) throw new OrderNotFoundError();
|
if (!existing) throw new OrderNotFoundError();
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface OrderRepository {
|
|||||||
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
|
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
|
||||||
}): Promise<OrderView>;
|
}): Promise<OrderView>;
|
||||||
findAll(): 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>;
|
findById(id: string): Promise<OrderView | undefined>;
|
||||||
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
|
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
|
||||||
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
|
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
|
||||||
@@ -25,6 +31,12 @@ export type OrderEvent =
|
|||||||
export interface OrderServicePort {
|
export interface OrderServicePort {
|
||||||
create(input: CreateOrderCommand): Promise<OrderView>;
|
create(input: CreateOrderCommand): Promise<OrderView>;
|
||||||
listOrders(): 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>;
|
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
|
||||||
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
|
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
|
||||||
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
|
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
|
||||||
|
|||||||
@@ -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> {
|
async findById(id: string): Promise<OrderView | undefined> {
|
||||||
const orderResult = await this.pool.query<OrderRow>(
|
const orderResult = await this.pool.query<OrderRow>(
|
||||||
'SELECT * FROM orders_orders WHERE id = $1',
|
'SELECT * FROM orders_orders WHERE id = $1',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const updateSettingsSchema = z.object({
|
|||||||
aiSeoTitlePrompt: z.string().max(2000).optional(),
|
aiSeoTitlePrompt: z.string().max(2000).optional(),
|
||||||
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
|
aiSeoDescriptionPrompt: z.string().max(4000).optional(),
|
||||||
aiProductDescriptionPrompt: 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(),
|
smtpHost: z.string().max(255).optional(),
|
||||||
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
|
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||||
smtpSecure: z.boolean().optional(),
|
smtpSecure: z.boolean().optional(),
|
||||||
@@ -52,6 +55,9 @@ const SETTING_KEYS: Record<string, string> = {
|
|||||||
aiSeoTitlePrompt: 'ai_seo_title_prompt',
|
aiSeoTitlePrompt: 'ai_seo_title_prompt',
|
||||||
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
|
aiSeoDescriptionPrompt: 'ai_seo_description_prompt',
|
||||||
aiProductDescriptionPrompt: 'ai_product_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',
|
smtpHost: 'smtp_host',
|
||||||
smtpPort: 'smtp_port',
|
smtpPort: 'smtp_port',
|
||||||
smtpSecure: 'smtp_secure',
|
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.',
|
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.',
|
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.',
|
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 ?? '',
|
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
|
||||||
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
||||||
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
|
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.',
|
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.',
|
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.',
|
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 ?? '',
|
smtpHost: map['smtp_host'] ?? process.env.SMTP_HOST ?? '',
|
||||||
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
smtpPort: map['smtp_port'] ?? process.env.SMTP_PORT ?? '465',
|
||||||
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
|
smtpSecure: (map['smtp_secure'] ?? process.env.SMTP_SECURE ?? 'true') !== 'false',
|
||||||
|
|||||||
114
project/src/shared/ai-content.ts
Normal file
114
project/src/shared/ai-content.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -24,3 +24,29 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { ProductCard } from '@/components/product-card';
|
import { ProductCard } from '@/components/product-card';
|
||||||
import { AddToCart } from '@/components/add-to-cart';
|
import { AddToCart } from '@/components/add-to-cart';
|
||||||
import { getProductBySlug, searchProducts } from '@/lib/api';
|
import { getProductBySlug, searchProducts } from '@/lib/api';
|
||||||
|
import { formatRichText } from '@/lib/format-rich-text';
|
||||||
import { absoluteUrl, metadataTitle } from '@/lib/seo';
|
import { absoluteUrl, metadataTitle } from '@/lib/seo';
|
||||||
import { breadcrumbJsonLd, JsonLdScript, productJsonLd } from '@/lib/seo/json-ld';
|
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 { slug } = await params;
|
||||||
const product = await loadProduct(slug);
|
const product = await loadProduct(slug);
|
||||||
const title = product.seoTitle ?? product.name;
|
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];
|
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
|
||||||
|
|
||||||
return {
|
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">
|
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg leading-8 text-stone-700">
|
<div className="text-lg leading-8 text-stone-700">
|
||||||
{product.description ? (
|
{product.description ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: product.description }} />
|
<div className="rich-text" dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }} />
|
||||||
) : (
|
) : (
|
||||||
'Producto del catálogo público de mercadodevida.'
|
'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">
|
<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>
|
<div>
|
||||||
<dt className="font-semibold text-emerald-950">URL pública</dt>
|
<dt className="font-semibold text-emerald-950">URL pública</dt>
|
||||||
|
|||||||
44
project/storefront/src/lib/format-rich-text.ts
Normal file
44
project/storefront/src/lib/format-rich-text.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
28
work/artifacts/F-103/implementer.md
Normal file
28
work/artifacts/F-103/implementer.md
Normal 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).
|
||||||
8
work/artifacts/F-103/leader-close.json
Normal file
8
work/artifacts/F-103/leader-close.json
Normal 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"
|
||||||
|
}
|
||||||
14
work/artifacts/F-103/qa.json
Normal file
14
work/artifacts/F-103/qa.json
Normal 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"
|
||||||
|
}
|
||||||
14
work/artifacts/F-103/reviewer.json
Normal file
14
work/artifacts/F-103/reviewer.json
Normal 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"
|
||||||
|
}
|
||||||
13
work/artifacts/F-103/security.json
Normal file
13
work/artifacts/F-103/security.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,55 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-099",
|
"feature_id": "F-103",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-099 SMTP password reset",
|
"action": "Close F-103",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "security gate",
|
"waiting_for": "security gate",
|
||||||
"updated_at": "2026-08-21T05:29:07Z",
|
"updated_at": "2026-08-21T05:55:17Z",
|
||||||
"timeline": [
|
"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",
|
"ts": "2026-08-20T20:33:00Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
@@ -147,6 +105,48 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-099 SMTP password reset"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user