feat(F-103): completed feature
This commit is contained in:
@@ -65,6 +65,8 @@ interface FormState {
|
||||
isParent: boolean;
|
||||
emoji: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
seoTitle: string;
|
||||
seoTitleManual: boolean;
|
||||
seoDescription: string;
|
||||
@@ -80,12 +82,31 @@ const EMPTY_FORM: FormState = {
|
||||
isParent: false,
|
||||
emoji: '',
|
||||
color: '',
|
||||
bgColor: '',
|
||||
textColor: '',
|
||||
seoTitle: '',
|
||||
seoTitleManual: false,
|
||||
seoDescription: '',
|
||||
seoDescManual: false,
|
||||
};
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🥬', '🥕', '🍎', '🥑', '🍞', '🧀', '🥛', '🍯', '🌾', '🥜',
|
||||
'🍵', '☕', '🍫', '🧴', '🧼', '🌿', '🌱', '🌸', '🐝', '🐇',
|
||||
'🧺', '♻️', '🏺', '🍶', '🥣', '🧊', '🍇', '🐔', '🐟', '🍊',
|
||||
];
|
||||
|
||||
const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [
|
||||
{ bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' },
|
||||
{ bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' },
|
||||
{ bg: '#FDECEA', text: '#B4533A', label: 'Terracota' },
|
||||
{ bg: '#E8F0FE', text: '#345995', label: 'Azul' },
|
||||
{ bg: '#F3E8FD', text: '#7C4DA0', label: 'Lila' },
|
||||
{ bg: '#FFF0F5', text: '#C2497D', label: 'Rosa' },
|
||||
{ bg: '#E0F7F4', text: '#0F766E', label: 'Turquesa' },
|
||||
{ bg: '#F5F5F4', text: '#44403C', label: 'Neutro' },
|
||||
];
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -95,6 +116,7 @@ export default function CategoriesPage() {
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -150,6 +172,8 @@ export default function CategoriesPage() {
|
||||
isParent: cat.isParent ?? false,
|
||||
emoji: cat.emoji ?? '',
|
||||
color: cat.color ?? '',
|
||||
bgColor: cat.bgColor ?? '',
|
||||
textColor: cat.textColor ?? '',
|
||||
seoTitle: (cat as any).seoTitle ?? '',
|
||||
seoTitleManual: true,
|
||||
seoDescription: (cat as any).seoDescription ?? '',
|
||||
@@ -169,7 +193,7 @@ export default function CategoriesPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
setSaving(true); setMsg(''); setGenerating(false);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
@@ -179,15 +203,32 @@ export default function CategoriesPage() {
|
||||
isParent: form.isParent,
|
||||
emoji: form.emoji || undefined,
|
||||
color: form.color || undefined,
|
||||
bgColor: form.bgColor || undefined,
|
||||
textColor: form.textColor || undefined,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
};
|
||||
let saved: Category;
|
||||
if (editing) {
|
||||
await categoriesApi.update(editing.id, payload);
|
||||
setMsg('Categoría actualizada');
|
||||
saved = await categoriesApi.update(editing.id, payload);
|
||||
} else {
|
||||
await categoriesApi.create(payload);
|
||||
setMsg('Categoría creada');
|
||||
saved = await categoriesApi.create(payload);
|
||||
}
|
||||
// Si faltan la descripción o los campos SEO, se generan con IA tras guardar.
|
||||
const needsAi = !form.description.trim() || !form.seoTitle.trim() || !form.seoDescription.trim();
|
||||
if (needsAi && saved?.id) {
|
||||
setGenerating(true);
|
||||
setMsg('✨ Guardado. Generando descripción y SEO con IA… esto puede tardar unos segundos.');
|
||||
try {
|
||||
await categoriesApi.generateSeo(saved.id);
|
||||
setMsg(`${editing ? 'Categoría actualizada' : 'Categoría creada'} — contenido generado con IA ✨`);
|
||||
} catch (aiError) {
|
||||
setMsg(`Guardada, pero la generación con IA falló: ${aiError instanceof Error ? aiError.message : 'error'}`);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
setMsg(editing ? 'Categoría actualizada' : 'Categoría creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
@@ -221,7 +262,16 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
<div className={`p-4 rounded-xl text-sm flex items-center gap-3 ${
|
||||
msg.startsWith('Error') || msg.startsWith('Guardada, pero')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: generating ? 'bg-blue-50 text-blue-700' : 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{generating && (
|
||||
<span className="inline-block h-4 w-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
@@ -293,37 +343,92 @@ export default function CategoriesPage() {
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Descripción opcional de la categoría"
|
||||
placeholder="Descripción opcional de la categoría (si la dejas vacía se genera con IA)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">✨ Si queda vacía, se generará automáticamente con IA al guardar.</p>
|
||||
</div>
|
||||
|
||||
{/* Emoji y color de la tarjeta */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Emoji</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
value={form.emoji}
|
||||
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value }))}
|
||||
maxLength={10}
|
||||
placeholder="🥜"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm text-2xl text-center focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
className="w-24 px-4 py-2.5 border border-gray-300 rounded-xl text-sm text-2xl text-center focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
{form.emoji && <span className="text-3xl">{form.emoji}</span>}
|
||||
{form.emoji && (
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, emoji: '' }))}
|
||||
className="text-xs text-gray-400 hover:text-red-500">Quitar</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-10 gap-1 border border-gray-200 rounded-xl p-2 max-h-28 overflow-y-auto">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button key={emoji} type="button" onClick={() => setForm((f) => ({ ...f, emoji }))}
|
||||
className={`text-xl p-1 rounded-lg transition-colors ${
|
||||
form.emoji === emoji ? 'bg-[#2D6A4F]/10 ring-1 ring-[#2D6A4F]' : 'hover:bg-gray-100'
|
||||
}`}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
|
||||
<input
|
||||
value={form.color}
|
||||
onChange={(e) => setForm((f) => ({ ...f, color: e.target.value }))}
|
||||
maxLength={200}
|
||||
placeholder="bg-[#70ad47]/10 text-[#70ad47] ó from-[#70ad47] to-[#40916C]"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Clase Tailwind para el color de la tarjeta (fondo o gradiente).</p>
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CARD_PRESETS.map((preset) => (
|
||||
<button key={preset.label} type="button" title={preset.label}
|
||||
onClick={() => setForm((f) => ({ ...f, bgColor: preset.bg, textColor: preset.text }))}
|
||||
className={`w-8 h-8 rounded-lg border-2 transition-transform hover:scale-110 flex items-center justify-center text-[10px] font-bold ${
|
||||
form.bgColor === preset.bg && form.textColor === preset.text ? 'border-[#2D6A4F] scale-110' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.bg, color: preset.text }}>
|
||||
Aa
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fondo (bg)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={form.bgColor || '#F1F8E9'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bgColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5" />
|
||||
<input value={form.bgColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bgColor: e.target.value }))}
|
||||
placeholder="#F1F8E9" maxLength={20}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Texto</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={form.textColor || '#2D6A4F'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, textColor: e.target.value }))}
|
||||
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer p-0.5" />
|
||||
<input value={form.textColor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, textColor: e.target.value }))}
|
||||
placeholder="#2D6A4F" maxLength={20}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(form.bgColor || form.textColor) && (
|
||||
<div className="rounded-lg px-3 py-2 text-sm font-medium flex items-center justify-between"
|
||||
style={{ backgroundColor: form.bgColor || '#F1F8E9', color: form.textColor || '#2D6A4F' }}>
|
||||
<span>{form.emoji || '🌿'} {form.name || 'Vista previa'}</span>
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, bgColor: '', textColor: '' }))}
|
||||
className="text-xs opacity-60 hover:opacity-100">Quitar color</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Colores de fondo y texto para las tarjetas de la categoría en la tienda.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,10 +473,10 @@ export default function CategoriesPage() {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name || !form.slug}
|
||||
disabled={saving || generating || !form.name || !form.slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
{generating ? '✨ Generando con IA...' : saving ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
|
||||
@@ -12,10 +12,13 @@ export default function ServerLogsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Logs del servidor</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo.
|
||||
Stream en tiempo real via SSE. Las líneas erróneas se resaltan en rojo. Máximo 200 líneas.
|
||||
</p>
|
||||
</div>
|
||||
<ServerLogViewer backendUrl={backendUrl} />
|
||||
{/* Altura acotada al viewport: el visor ocupa solo la parte visible y el resto hace scroll interno. */}
|
||||
<div className="h-[calc(100vh-220px)] min-h-[320px]">
|
||||
<ServerLogViewer backendUrl={backendUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +149,14 @@ export default function SettingsPage() {
|
||||
{field('aiApiKey', 'API key', { type: 'password', placeholder: form?.aiApiKeyConfigured ? 'API key configurada (escribe para reemplazar)' : 'sk-...' })}
|
||||
{field('aiSeoTitlePrompt', 'Prompt para Título SEO', { rows: 4, hint: 'Usa {{name}}, {{description}} y {{brand}} como variables.' })}
|
||||
{field('aiSeoDescriptionPrompt', 'Prompt para Descripción SEO (Google)', { rows: 5, hint: 'Usa {{name}}, {{description}} y {{brand}} como variables.' })}
|
||||
{field('aiProductDescriptionPrompt', 'Prompt para Descripción del producto', { rows: 5, hint: 'Se usa solo cuando la descripción normal está vacía. Variables: {{name}}, {{description}} y {{brand}}.' })}
|
||||
{field('aiProductDescriptionPrompt', 'Prompt para Descripción del producto', { rows: 5, hint: 'Se usa solo cuando la descripción normal está vacía. Debe pedir HTML (párrafos y listas) para que se renderice bonito en la tienda. Variables: {{name}}, {{description}} y {{brand}}.' })}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-1">Categorías</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">Se usan cuando la descripción o los campos SEO de una categoría están vacíos. Variables: {'{{name}}'} y {'{{description}}'}.</p>
|
||||
</div>
|
||||
{field('aiCategoryDescriptionPrompt', 'Prompt para Descripción de categoría', { rows: 4, hint: 'Variables: {{name}} y {{description}}.' })}
|
||||
{field('aiCategorySeoTitlePrompt', 'Prompt para Título SEO de categoría', { rows: 4, hint: 'Variables: {{name}} y {{description}}.' })}
|
||||
{field('aiCategorySeoDescriptionPrompt', 'Prompt para Descripción SEO de categoría', { rows: 5, hint: 'Variables: {{name}} y {{description}}.' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -117,7 +117,8 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
|
||||
const json = raw.slice(6);
|
||||
setLogs(prev => {
|
||||
const next = [...prev, parseLine(json)];
|
||||
return next.length > 2000 ? next.slice(-2000) : next;
|
||||
// Máximo 200 líneas para no sobrecargar el servidor ni el navegador.
|
||||
return next.length > 200 ? next.slice(-200) : next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -187,7 +188,7 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
|
||||
className="flex-1 overflow-y-auto bg-[#0d1117] font-mono text-xs leading-relaxed"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<table className="w-full">
|
||||
<table className="w-full table-fixed">
|
||||
<tbody>
|
||||
{logs.map((entry, i) => (
|
||||
<tr
|
||||
|
||||
@@ -51,13 +51,13 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
const isCreate = !productId;
|
||||
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [desc, setDesc] = useState('');
|
||||
const [brandId, setBrandId] = useState('');
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
@@ -140,7 +140,8 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
|
||||
const handleNameChange = (v: string) => {
|
||||
setName(v);
|
||||
if (!slugManual) setSlug(slugify(v));
|
||||
// El slug se auto-genera solo al crear; en edición se conserva el existente.
|
||||
if (isCreate) setSlug(slugify(v));
|
||||
if (!seoTitleManual) setSeoTitle(v);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en mercadodevida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
@@ -165,18 +166,24 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
if (isCreate) saved = await productsApi.create(payload);
|
||||
else saved = await productsApi.update(productId, payload);
|
||||
if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
|
||||
setGenerating(true);
|
||||
setSuccess('✨ Generando contenido con IA… esto puede tardar unos segundos.');
|
||||
try {
|
||||
saved = await productsApi.generateSeo(saved.id);
|
||||
setDesc(saved.description ?? '');
|
||||
setSeoTitle(saved.seoTitle ?? '');
|
||||
setSeoDesc(saved.seoDescription ?? '');
|
||||
setSuccess(isCreate ? '¡Producto creado! Contenido generado con IA.' : 'Cambios guardados. Contenido generado con IA.');
|
||||
} catch (generationError) {
|
||||
setError(generationError instanceof Error ? generationError.message : 'No se pudieron generar los campos SEO');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
|
||||
}
|
||||
snapRef.current = getSnap();
|
||||
dirtyRef.current = false;
|
||||
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
|
||||
if (isCreate) router.push(`/products/${saved.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
@@ -214,14 +221,19 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1">← Productos</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
<button onClick={handleSave} disabled={saving || generating}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
|
||||
{generating ? '✨ Generando con IA...' : saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
|
||||
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
|
||||
{generating ? (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-700 flex items-center gap-3">
|
||||
<span className="inline-block h-4 w-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin shrink-0" />
|
||||
✨ Generando descripción y campos SEO con IA en segundo plano… no cierres esta página.
|
||||
</div>
|
||||
) : success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
|
||||
@@ -238,22 +250,28 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
{/* ── GENERAL ── */}
|
||||
{tab === 'general' && (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
|
||||
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
|
||||
placeholder="Ej: Almendras Crudas Ecológicas"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
|
||||
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{slugManual ? 'editado manualmente' : 'auto-generado'}
|
||||
</span>
|
||||
<span className="text-xs text-[#2D6A4F] font-medium">auto-generado</span>
|
||||
</div>
|
||||
<input type="text" value={slug}
|
||||
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<input type="text" value={slug} disabled readOnly
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm font-mono bg-gray-50 text-gray-500 cursor-not-allowed outline-none" />
|
||||
<p className="mt-1 text-xs text-gray-400">Se genera automáticamente a partir del nombre y no se puede editar manualmente.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-5 items-start">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
|
||||
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
|
||||
placeholder="Ej: Almendras Crudas Ecológicas"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 sm:mt-[34px] cursor-pointer px-4 py-2.5 border border-gray-200 rounded-xl bg-gray-50">
|
||||
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="text-sm font-semibold text-gray-900 whitespace-nowrap">⭐ Producto destacado</span>
|
||||
<span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
|
||||
@@ -263,7 +281,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
placeholder="Descripción detallada del producto…"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
|
||||
<select value={brandId} onChange={e => setBrandId(e.target.value)}
|
||||
@@ -279,27 +297,18 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Fecha de caducidad</label>
|
||||
<span className="text-xs text-gray-400">opcional</span>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">
|
||||
Fecha de caducidad <span className="text-xs text-gray-400 font-normal">(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
value={expirationDate}
|
||||
onChange={(e) => setExpirationDate(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-3">
|
||||
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="text-sm font-semibold text-gray-900">⭐ Producto destacado</span>
|
||||
<span className="text-xs text-gray-400">(aparece en la home)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -101,9 +101,9 @@ export const ordersApi = {
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Order[]; total: number }>(
|
||||
return api.get<{ items: import('@/types').Order[]; total: number } | import('@/types').Order[]>(
|
||||
`/api/orders${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
).then((data) => (Array.isArray(data) ? { items: data, total: data.length } : data));
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
|
||||
transition: (id: string, state: string) =>
|
||||
@@ -156,6 +156,8 @@ export const categoriesApi = {
|
||||
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
|
||||
generateSeo: (id: string) =>
|
||||
api.post<import('@/types').Category>(`/api/categories/${id}/generate-seo`),
|
||||
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
|
||||
};
|
||||
|
||||
@@ -337,6 +339,9 @@ export interface StoreSettings {
|
||||
aiSeoTitlePrompt: string;
|
||||
aiSeoDescriptionPrompt: string;
|
||||
aiProductDescriptionPrompt: string;
|
||||
aiCategoryDescriptionPrompt: string;
|
||||
aiCategorySeoTitlePrompt: string;
|
||||
aiCategorySeoDescriptionPrompt: string;
|
||||
smtpHost: string;
|
||||
smtpPort: string;
|
||||
smtpSecure: boolean;
|
||||
|
||||
@@ -164,6 +164,8 @@ export interface Category {
|
||||
isParent?: boolean;
|
||||
emoji?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
textColor?: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user