feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import type { Brand } from '@/types';
import { brandsApi } from '@/lib/api-client';
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function autoSeoTitle(name: string): string {
return name;
}
function autoSeoDescription(name: string): string {
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
}
export default function BrandsPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Brand | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDescription, setSeoDescription] = useState('');
const [seoDescriptionManual, setSeoDescriptionManual] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await brandsApi.list();
setBrands((data as { items?: Brand[] }).items ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setName(''); setSlug(''); setSlugManual(false);
setSeoTitle(''); setSeoTitleManual(false);
setSeoDescription(''); setSeoDescriptionManual(false);
setShowForm(true);
};
const openEdit = (b: Brand) => {
setEditing(b);
setName(b.name);
setSlug(b.slug); setSlugManual(true);
setSeoTitle(b.seoTitle ?? ''); setSeoTitleManual(true);
setSeoDescription(b.seoDescription ?? ''); setSeoDescriptionManual(true);
setShowForm(true);
};
const handleNameChange = (value: string) => {
setName(value);
if (!slugManual) setSlug(slugify(value));
if (!seoTitleManual) setSeoTitle(autoSeoTitle(value));
if (!seoDescriptionManual) setSeoDescription(autoSeoDescription(value));
};
const handleSave = async () => {
setSaving(true); setMsg('');
const payload = {
name,
slug,
seoTitle: seoTitle || undefined,
seoDescription: seoDescription || undefined,
};
try {
if (editing) {
await brandsApi.update(editing.id, payload);
setMsg('Marca actualizada');
} else {
await brandsApi.create(payload);
setMsg('Marca creada');
}
setShowForm(false);
load();
} catch (e) {
setMsg(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Eliminar esta marca?')) return;
try {
await brandsApi.delete!(id);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error');
}
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Marcas</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva marca</button>
</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>
)}
{showForm && (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="font-semibold text-gray-900">{editing ? 'Editar marca' : 'Nueva marca'}</h2>
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Ej: NaturGreen"
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>
{/* Slug */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Slug *</label>
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
</div>
<input
value={slug}
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
placeholder="auto-generado"
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>
{/* SEO Title */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">SEO Title</label>
<span className="text-xs text-gray-400">{seoTitleManual ? 'editado' : 'auto'}</span>
</div>
<div className="relative">
<input
value={seoTitle}
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
placeholder="auto-generado desde nombre"
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs ${seoTitle.length > 60 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
{seoTitle.length}/60
</span>
</div>
</div>
{/* SEO Description */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">SEO Description</label>
<span className="text-xs text-gray-400">{seoDescriptionManual ? 'editado' : 'auto'}</span>
</div>
<div className="relative">
<textarea
value={seoDescription}
onChange={(e) => { setSeoDescriptionManual(true); setSeoDescription(e.target.value); }}
rows={2}
placeholder="auto-generado desde nombre"
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<span className={`absolute right-3 bottom-2 text-xs ${seoDescription.length > 160 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
{seoDescription.length}/160
</span>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !name || !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'}
</button>
<button
onClick={() => setShowForm(false)}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> :
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Nombre', 'Slug', 'SEO Title'].map(h => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{brands.map(b => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
<td className="px-4 py-3.5">
<div className="flex gap-2">
<button onClick={() => openEdit(b)} className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
<button onClick={() => handleDelete(b.id)} className="text-xs text-red-600 hover:underline">Eliminar</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
}
</div>
</div>
);
}