feat(ADM-018): completed feature
This commit is contained in:
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal file
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user