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,178 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Redirect if already logged in
useEffect(() => {
fetch('/api/auth/me')
.then((r) => r.json())
.then((data) => {
if (data.id) {
router.push('/');
}
})
.catch(() => {});
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
setError(
data?.message ||
(data?.code === 'TOO_MANY_ATTEMPTS'
? 'Demasiados intentos. Espera un momento.'
: 'Email o contraseña incorrectos'),
);
} else {
router.push('/');
}
} catch {
setError('Error de conexión. Intenta de nuevo.');
} finally {
setLoading(false);
}
};
return (
<div
style={{ minHeight: '100vh' }}
className="flex items-center justify-center bg-gray-50 px-4"
>
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-2">
<svg
className="w-10 h-10 text-[#2D6A4F]"
viewBox="0 0 32 32"
fill="none"
>
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" />
<path
d="M10 20c2-4 4-8 6-10s4 6 6 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="16" cy="10" r="2" fill="currentColor" />
</svg>
</div>
<h1
className="text-2xl font-bold text-gray-900"
style={{ fontFamily: 'var(--font-heading)' }}
>
MercadoDeVida
</h1>
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
</div>
{/* Card */}
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
<h2 className="text-lg font-bold text-gray-900 mb-6 text-center">
Iniciar sesión
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@mercadodevida.es"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
disabled={loading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Contraseña
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Entrando...
</>
) : (
'Iniciar sesión'
)}
</button>
</form>
</div>
<p className="text-center text-sm text-gray-400 mt-6">
&copy; {new Date().getFullYear()} MercadoDeVida
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { auditApi, type AuditEntry } from '@/lib/api-client';
const PAGE_SIZE = 50;
const ACTION_COLORS: Record<string, string> = {
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
'admin.mfa.status': 'bg-purple-100 text-purple-700',
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
'auth.login': 'bg-blue-100 text-blue-700',
'auth.logout': 'bg-gray-100 text-gray-600',
'product.created': 'bg-green-100 text-green-700',
'product.updated': 'bg-green-100 text-green-700',
'product.deleted': 'bg-red-100 text-red-700',
'order.placed': 'bg-indigo-100 text-indigo-700',
'order.state_changed': 'bg-indigo-100 text-indigo-700',
};
function colorForAction(action: string): string {
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
useEffect(() => {
const t = setTimeout(() => setDebounced(filter), 400);
return () => clearTimeout(t);
}, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
return (
<div className="p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
<p className="text-sm text-gray-500 mt-0.5">
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
</p>
</div>
<div className="max-w-xs">
<div className="relative">
<input type="text" placeholder="Filtrar por acción..." value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(entry => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
{entry.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Category } from '@/types';
import { categoriesApi } 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 CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Category) => void; onDelete: (id: string) => void }) {
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{cat.children && cat.children.length > 0 && <span className="text-gray-300">📁</span>}
<div>
<p className="text-sm font-medium text-gray-900">{cat.name}</p>
<p className="text-xs text-gray-400">/{cat.slug}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{cat.parentId ? 'Sí' : 'Raíz'}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => onEdit(cat)} className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => onDelete(cat.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
);
}
export default function CategoriesPage() {
const [tree, setTree] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [description, setDescription] = useState('');
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDescription, setSeoDescription] = useState('');
const [seoDescManual, setSeoDescManual] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await categoriesApi.list() as { items?: Category[] };
setTree(data?.items ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setName(''); setSlug(''); setSlugManual(false);
setDescription('');
setSeoTitle(''); setSeoTitleManual(false);
setSeoDescription(''); setSeoDescManual(false);
setShowForm(true);
};
const openEdit = (cat: Category) => {
setEditing(cat);
setName(cat.name); setSlug(cat.slug); setSlugManual(true);
setDescription(cat.description ?? '');
setSeoTitle((cat as any).seoTitle ?? ''); setSeoTitleManual(true);
setSeoDescription((cat as any).seoDescription ?? ''); setSeoDescManual(true);
setShowForm(true);
};
const handleNameChange = (value: string) => {
setName(value);
if (!slugManual) setSlug(slugify(value));
if (!seoTitleManual) setSeoTitle(value);
if (!seoDescManual) setSeoDescription(`${value} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
};
const handleSave = async () => {
setSaving(true); setMsg('');
try {
if (editing) {
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
setMsg('Categoría actualizada');
} else {
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
setMsg('Categoría 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 categoría?')) return;
try {
await categoriesApi.delete(id);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error al eliminar');
}
};
const flat = (cats: Category[]): Category[] =>
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Categorías</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">
+ Nueva categoría
</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 categoría' : 'Nueva categoría'}</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: Alimentación"
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>
{/* Descripción */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Descripción opcional de la categoría"
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"
/>
</div>
{/* SEO Title */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Título SEO (Google)</label>
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoTitleManual ? 'editado' : 'copiado del nombre'}
</span>
</div>
<input
type="text"
value={seoTitle}
maxLength={60}
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
placeholder="Título para Google (copiado del nombre)"
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 className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
</div>
{/* SEO Description */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Descripción SEO (Google)</label>
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoDescManual ? 'editada' : 'auto-generada'}
</span>
</div>
<textarea
value={seoDescription}
maxLength={160}
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
rows={2}
placeholder="Descripción para Google (max 160 caracteres)"
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"
/>
<div className="mt-1 text-xs text-gray-400">{seoDescription.length}/160</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>
) : flat(tree).length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay categorías</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Nombre</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Subcategoría</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">
{flat(tree).map((c) => (
<CategoryRow key={c.id} cat={c} onEdit={openEdit} onDelete={handleDelete} />
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { cmsApi } from '@/lib/api-client';
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
export default function CmsPage() {
const [items, setItems] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({ slug: '', title: '', body: '' });
const load = useCallback(async () => {
setLoading(true);
try { const d = await cmsApi.list() as { items: Page[] }; setItems(d.items ?? []); }
catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
setMsg('');
try {
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
setMsg('Página creada');
setShowForm(false);
setForm({ slug: '', title: '', body: '' });
load();
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
};
const togglePublish = async (id: string, currentStatus: string) => {
try {
if (currentStatus === 'published') await cmsApi.unpublish(id);
else await cmsApi.publish(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">Páginas CMS</h1>
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</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">Nueva página</h2>
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label,t]) => (
<div key={k}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input value={(form as Record<string,string>)[k]} onChange={e => setForm({...form,[k]: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" />
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contenido *</label>
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
</div>
<div className="flex gap-3">
<button onClick={handleSave} disabled={!form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">Crear</button>
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
</div>
</div>
)}
<div className="space-y-3">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay páginas</div> :
items.map(p => (
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-gray-900 text-sm">{p.title}</p>
<p className="text-xs text-gray-400 font-mono">/{p.slug}</p>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
{p.status === 'published' ? 'Publicada' : 'Borrador'}
</span>
<button onClick={() => togglePublish(p.id, p.status)} className="text-xs text-[#2D6A4F] hover:underline">
{p.status === 'published' ? 'Despublicar' : 'Publicar'}
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { customersApi } from '@/lib/api-client';
import type { Customer } from '@/types';
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!customer) return;
setSaving(true);
setMsg('');
try {
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
await fetch(`/api/customers/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: customer.role }),
});
setMsg('Cliente actualizado');
} catch {
setMsg('Error al guardar');
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-8 text-gray-400">Cargando...</div>;
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
return (
<div className="p-8 max-w-2xl space-y-6">
<div className="flex items-center gap-4 mb-6">
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700"> Clientes</button>
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
<p className="text-sm text-gray-900">{customer.email}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
<select
value={customer.role}
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
>
<option value="customer">Cliente</option>
<option value="admin">Administrador</option>
</select>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
<p className="text-sm text-gray-900">
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
</p>
</div>
</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>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
);
}

View File

@@ -0,0 +1,298 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Customer } from '@/types';
import { customersApi } from '@/lib/api-client';
const PAGE_SIZE = 20;
// ── Modal genérico ────────────────────────────────────────────────────────────
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
// ── Formulario crear cliente ─────────────────────────────────────────────────
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.create({ email, password, displayName: displayName || undefined, phone: phone || undefined });
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear cliente');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
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>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
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>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Opcional"
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>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
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>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Creando...' : 'Crear cliente'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Formulario editar cliente ────────────────────────────────────────────────
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setDisplayName(customer.displayName || '');
setPhone(customer.phone || '');
}, [customer]);
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.update(customer.id, {
displayName: displayName || undefined,
phone: phone || undefined,
});
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={customer.email} disabled
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(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" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
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>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 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...' : 'Guardar cambios'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Página principal ───────────────────────────────────────────────────────────
export default function CustomersPage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [msg, setMsg] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 400);
return () => clearTimeout(t);
}, [search]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await customersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
setCustomers(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} cliente${total !== 1 ? 's' : ''}` : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
>
+ Nuevo cliente
</button>
</div>
{msg && (
<div className="p-4 rounded-xl text-sm bg-green-50 text-green-700">{msg}</div>
)}
{/* Buscador */}
<div className="relative max-w-sm">
<input type="search" placeholder="Buscar por email..." value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
{/* Tabla */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : customers.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">👥</p>
<p className="text-gray-500 text-sm">No hay clientes</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Email', 'Nombre', 'Teléfono', 'Rol', 'Alta', ''].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{c.email}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.displayName || '—'}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.phone || '—'}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
c.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>{c.role}</span>
</td>
<td className="px-4 py-3.5 text-sm text-gray-500">
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
</td>
<td className="px-4 py-3.5">
<button onClick={() => setEditing(c)}
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Paginación */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">Página {page + 1}</p>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Anterior
</button>
<button onClick={() => setPage((p) => p + 1)} disabled={customers.length < PAGE_SIZE}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Siguiente
</button>
</div>
</div>
</>
)}
</div>
{/* Modal crear */}
{showCreate && (
<Modal title="Nuevo cliente" onClose={() => setShowCreate(false)}>
<CreateForm onClose={() => setShowCreate(false)} onCreated={handleCreated} />
</Modal>
)}
{/* Modal editar */}
{editing && (
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,348 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { productsApi, inventoryApi } from '@/lib/api-client';
import type { Product, ProductVariant, StockAvailability } from '@/types';
interface VariantRow {
productId: string;
productName: string;
variant: ProductVariant;
stock: StockAvailability | null;
loading: boolean;
editing: boolean;
editValue: string;
saving: boolean;
msg: string;
}
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
const STOCK_LABELS: Record<StockFilter, string> = {
all: 'Todos',
in_stock: 'En stock',
low_stock: 'Stock bajo',
out_of_stock: 'Sin stock',
};
function StockBadge({ qty }: { qty: number }) {
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>;
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>;
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>;
}
export default function InventoryPage() {
const [rows, setRows] = useState<VariantRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<StockFilter>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// Load products + variants + stock
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const { items: products } = await productsApi.list({
limit: 100,
q: debouncedSearch || undefined,
});
const variantRows: VariantRow[] = [];
for (const product of products ?? []) {
const { items: variants } = await productsApi.getVariants(product.id);
for (const variant of variants ?? []) {
variantRows.push({
productId: product.id,
productName: product.name,
variant,
stock: null,
loading: true,
editing: false,
editValue: '',
saving: false,
msg: '',
});
}
}
setRows(variantRows);
// Load stock for each variant
for (const vr of variantRows) {
inventoryApi.getAvailability(vr.variant.id)
.then((stock) => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === vr.variant.id
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
: r,
),
);
})
.catch(() => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === vr.variant.id ? { ...r, loading: false, editValue: '0' } : r,
),
);
});
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
} finally {
setLoading(false);
}
}, [debouncedSearch]);
useEffect(() => { load(); }, [load]);
// Filter rows
const filtered = rows.filter((r) => {
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
return true;
});
const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
const lowStockCount = rows.filter((r) => {
const q = r.stock?.availableQuantity ?? 0;
return q > 0 && q < 5;
}).length;
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-sm text-gray-500 mt-0.5">{rows.length} variantes</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' },
{ label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' },
{ label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' },
].map(({ label, count, cls }) => (
<div key={label} className={`p-4 rounded-xl border ${cls}`}>
<p className="text-2xl font-bold">{count}</p>
<p className="text-sm font-medium">{label}</p>
</div>
))}
</div>
{/* Search + filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<input
type="search"
placeholder="Buscar por producto o SKU..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
<div className="flex gap-2">
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
filter === f
? 'bg-[#2D6A4F] text-white'
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
}`}
>
{STOCK_LABELS[f]}
</button>
))}
</div>
<button
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Recargar
</button>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando inventario...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : filtered.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">No hay variantes para este filtro</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Producto</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{filtered.map((row) => (
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{row.variant.sku}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-400">{row.variant.ean ?? '—'}</td>
<td className="px-4 py-3">
{row.loading ? (
<span className="text-gray-300"></span>
) : row.editing ? (
<div className="flex items-center gap-1">
<input
type="number"
min={0}
value={row.editValue}
onChange={(e) =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, editValue: e.target.value }
: r,
),
)
}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={async () => {
const qty = parseInt(row.editValue, 10);
if (isNaN(qty) || qty < 0) return;
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, saving: true } : r,
),
);
try {
const result = await inventoryApi.setStock(row.variant.id, qty);
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? {
...r,
stock: { available: result.available > 0, availableQuantity: result.available },
editing: false,
saving: false,
msg: '✓',
}
: r,
),
);
setTimeout(() => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, msg: '' } : r,
),
);
}, 3000);
} catch {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, saving: false, msg: 'Error' }
: r,
),
);
}
}}
disabled={row.saving}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{row.saving ? '...' : 'OK'}
</button>
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? {
...r,
editing: false,
editValue: String(r.stock?.availableQuantity ?? 0),
}
: r,
),
)
}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{row.stock?.availableQuantity ?? '—'}
</span>
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, editing: true } : r,
),
)
}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar stock"
>
</button>
</div>
)}
</td>
<td className="px-4 py-3">
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
</td>
<td className="px-4 py-3">
{row.msg && (
<span className={`text-xs ${row.msg === '✓' ? 'text-green-600' : 'text-red-600'}`}>
{row.msg}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
import { visibleNavItems, type NavItem } from '@/lib/permissions';
import type { Role } from '@/types';
function Sidebar({
navItems,
user,
onLogout,
}: {
navItems: NavItem[];
user: { email: string; role: Role };
onLogout: () => void;
}) {
const pathname = usePathname();
return (
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
{/* Logo */}
<div className="px-4 py-5 border-b border-gray-100">
<img
src="/images/logo-main.png"
alt="MercadoDeVida"
className="h-9 w-auto object-contain mx-auto"
/>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{navItems.map((item) => {
const active =
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all
${
active
? 'bg-[#2D6A4F]/10 text-[#2D6A4F] border-l-[3px] border-[#2D6A4F]'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}
`}
>
<span className="text-base">{item.icon}</span>
<span className="truncate">{item.label}</span>
{item.badge != null && item.badge > 0 && (
<span className="ml-auto bg-[#E76F51] text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[18px] text-center">
{item.badge}
</span>
)}
</Link>
);
})}
</nav>
{/* User footer */}
<div className="px-3 py-4 border-t border-gray-100">
<div className="px-3 py-2 mb-2">
<p className="text-xs text-gray-400 truncate">{user.email}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
<button
onClick={onLogout}
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
Cerrar sesión
</button>
</div>
</div>
);
}
function DashboardShell({ children }: { children: React.ReactNode }) {
const { user, loading, logout } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (!user) return null;
const navItems = visibleNavItems(user.role);
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar navItems={navItems} user={user} onLogout={logout} />
<main className="flex-1 min-w-0">
{children}
</main>
</div>
);
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<DashboardShell>{children}</DashboardShell>
</AuthProvider>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import type { Order, OrderState } from '@/types';
import { ordersApi } from '@/lib/api-client';
const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado',
PROCESSING: 'Procesando',
SHIPPED: 'Enviado',
DELIVERED: 'Entregado',
CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<OrderState, string> = {
PENDING: 'bg-amber-100 text-amber-800',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
PAID: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
};
const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
DELIVERED: ['PARTIALLY_REFUNDED'],
CANCELLED: [],
REFUNDED: [],
PARTIALLY_REFUNDED: [],
};
const ACTION_LABELS: Record<OrderState, string> = {
AWAITING_PAYMENT: 'Marcar como Pagado',
PAID: 'Procesar pedido',
PROCESSING: 'Marcar como Enviado',
SHIPPED: 'Marcar como Entregado',
DELIVERED: 'Reembolso parcial',
CANCELLED: 'Cancelar pedido',
PENDING: 'Marcar como Pagado',
REFUNDED: 'Reembolsar',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [transitioning, setTransitioning] = useState(false);
const [showConfirm, setShowConfirm] = useState<OrderState | null>(null);
const [confirmReason, setConfirmReason] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await ordersApi.get(id);
setOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
const handleTransition = async (nextState: OrderState) => {
setTransitioning(true);
try {
const updated = await ordersApi.transition(id, nextState);
setOrder(updated);
setShowConfirm(null);
setConfirmReason('');
} catch (err) {
alert(err instanceof Error ? err.message : 'Error al cambiar estado');
} finally {
setTransitioning(false);
}
};
if (loading) {
return (
<div className="p-8 flex items-center justify-center min-h-64">
<div className="text-gray-400">Cargando...</div>
</div>
);
}
if (error || !order) {
return (
<div className="p-8">
<p className="text-red-600">{error || 'Pedido no encontrado'}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline mt-2">
Reintentar
</button>
</div>
);
}
const currentState = order.state as OrderState;
const allowed = ALLOWED_TRANSITIONS[currentState] ?? [];
return (
<div className="p-8">
{/* Back */}
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
Volver a pedidos
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono">#{order.id.slice(0, 8)}</h1>
<p className="text-sm text-gray-500 mt-1">
{new Date(order.createdAt).toLocaleString('es-ES', {
dateStyle: 'long',
timeStyle: 'short',
})}
</p>
</div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${STATE_COLORS[currentState]}`}>
<span className="w-2 h-2 rounded-full bg-current" />
{STATE_LABELS[currentState]}
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Actions */}
{allowed.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Acciones</h2>
<div className="flex flex-wrap gap-2">
{allowed.map((next) => (
<button
key={next}
onClick={() => setShowConfirm(next)}
disabled={transitioning}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{ACTION_LABELS[next] ?? next}
</button>
))}
</div>
</div>
)}
{/* Order items */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Productos</h2>
<div className="space-y-3">
{order.items.map((item) => (
<div key={item.id} className="flex justify-between items-start py-2 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
<p className="text-xs text-gray-400">
{item.quantity} × {formatPrice(item.unitPriceCents)}
{item.discountCents > 0 && ` (-${formatPrice(item.discountCents)})`}
</p>
</div>
<p className="text-sm font-bold text-gray-900 ml-4">
{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}
</p>
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Totals */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatPrice(order.subtotalCents)}</span>
</div>
{order.discountCents > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Descuento</span>
<span className="font-medium text-green-600">-{formatPrice(order.discountCents)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600">IVA</span>
<span className="font-medium">{formatPrice(order.taxCents)}</span>
</div>
<div className="border-t border-gray-200 pt-2 mt-2 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-xl font-bold text-[#2D6A4F]">{formatPrice(order.totalCents)}</span>
</div>
</div>
</div>
{/* Timeline */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Historial</h2>
<div className="space-y-3">
<div className="flex gap-3">
<div className="w-2 h-2 rounded-full bg-[#2D6A4F] mt-1.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-gray-900">{STATE_LABELS[currentState]}</p>
<p className="text-xs text-gray-400">
{new Date(order.createdAt).toLocaleString('es-ES')}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Confirmation Modal */}
{showConfirm && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-2">
Confirmar cambio de estado
</h3>
<p className="text-sm text-gray-600 mb-4">
¿{ACTION_LABELS[showConfirm] ?? showConfirm}?
</p>
{(showConfirm === 'CANCELLED' || showConfirm === 'REFUNDED') && (
<textarea
value={confirmReason}
onChange={(e) => setConfirmReason(e.target.value)}
placeholder="Motivo (opcional)"
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none mb-4 resize-none"
/>
)}
<div className="flex gap-3 justify-end">
<button
onClick={() => { setShowConfirm(null); setConfirmReason(''); }}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleTransition(showConfirm)}
disabled={transitioning}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{transitioning ? 'Guardando...' : 'Confirmar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import type { Order, OrderState } from '@/types';
import { ordersApi } from '@/lib/api-client';
const ORDER_STATES: OrderState[] = [
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
];
const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado',
PROCESSING: 'Procesando',
SHIPPED: 'Enviado',
DELIVERED: 'Entregado',
CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<OrderState, string> = {
PENDING: 'bg-amber-100 text-amber-800',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
PAID: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function timeAgo(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 30) return `Hace ${diffDays} días`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
export default function OrdersPage() {
const [orders, setOrders] = useState<Order[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterState, setFilterState] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await ordersApi.list({
status: filterState || undefined,
q: debouncedSearch || undefined,
limit: 20,
});
setOrders(data.items);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [filterState, debouncedSearch]);
useEffect(() => { load(); }, [load]);
return (
<div className="p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
<p className="text-sm text-gray-500 mt-0.5">{orders?.length ?? 0} pedidos</p>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="relative flex-1 max-w-xs">
<input
type="search"
placeholder="Buscar por ID o email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
<select
value={filterState}
onChange={(e) => setFilterState(e.target.value)}
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
>
<option value="">Todos los estados</option>
{ORDER_STATES.map((s) => (
<option key={s} value={s}>{STATE_LABELS[s]}</option>
))}
</select>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div>
) : !orders || orders.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">🧾</p>
<p className="text-gray-500 text-sm">No hay pedidos</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => (
<th
key={h}
className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{orders.map((o) => (
<tr key={o.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3.5">
<Link
href={`/orders/${o.id}`}
className="text-sm font-mono text-[#2D6A4F] hover:underline"
>
{o.id.slice(0, 8)}...
</Link>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{timeAgo(o.createdAt)}</p>
<p className="text-xs text-gray-400">
{new Date(o.createdAt).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</td>
<td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATE_COLORS[o.state]}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{STATE_LABELS[o.state]}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api-client';
interface Stats {
ordersToday: number;
revenueTodayCents: number;
revenueTodayFormatted: string;
ordersByState: Record<string, number>;
outOfStockVariants: number;
totalActiveProducts: number;
newCustomersThisMonth: number;
generatedAt: string;
}
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
const STATE_LABELS: Record<string, string> = {
PENDING: 'Pendientes',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagados',
PROCESSING: 'Procesando',
SHIPPED: 'Enviados',
DELIVERED: 'Entregados',
CANCELLED: 'Cancelados',
REFUNDED: 'Reembolsados',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<string, string> = {
PENDING: 'bg-amber-100 text-amber-700',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-700',
PAID: 'bg-green-100 text-green-700',
PROCESSING: 'bg-blue-100 text-blue-700',
SHIPPED: 'bg-indigo-100 text-indigo-700',
DELIVERED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-gray-100 text-gray-600',
REFUNDED: 'bg-red-100 text-red-700',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
};
function KPICard({
label,
value,
sub,
icon,
trend,
}: {
label: string;
value: string;
sub?: string;
icon: string;
trend?: 'up' | 'down' | 'neutral';
}) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{label}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
</div>
<div className="text-3xl">{icon}</div>
</div>
</div>
);
}
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
const pct = total > 0 ? (count / total) * 100 : 0;
const label = STATE_LABELS[state] ?? state;
const color = STATE_COLORS[state] ?? 'bg-gray-100 text-gray-700';
return (
<div className="flex items-center gap-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium min-w-[100px] ${color}`}>
{label}
</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#2D6A4F] rounded-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-sm font-semibold text-gray-700 min-w-[32px] text-right">{count}</span>
</div>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
api
.get<Stats>('/admin/stats')
.then(setStats)
.catch(() => setError('No se pudieron cargar las estadísticas'))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 bg-gray-100 rounded" />
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error ?? 'Error desconocido'}
</div>
</div>
);
}
const totalOrders = Object.values(stats.ordersByState).reduce((a, b) => a + b, 0);
const ordersByStateSorted = Object.entries(stats.ordersByState).sort(
([, a], [, b]) => b - a,
);
return (
<div className="p-8 space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Pedidos hoy"
value={String(stats.ordersToday)}
sub="Órdenes del día"
icon="📦"
/>
<KPICard
label="Ingresos hoy"
value={formatCents(stats.revenueTodayCents)}
sub="Revenue del día"
icon="💶"
/>
<KPICard
label="Productos activos"
value={String(stats.totalActiveProducts)}
sub="En el catálogo"
icon="🌿"
/>
<KPICard
label="Sin stock"
value={String(stats.outOfStockVariants)}
sub="Variantes agotadas"
icon="⚠️"
/>
</div>
{/* Secondary KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<KPICard
label="Clientes nuevos"
value={String(stats.newCustomersThisMonth)}
sub="Este mes"
icon="👥"
/>
<KPICard
label="Total pedidos"
value={String(totalOrders)}
sub="En el sistema"
icon="📋"
/>
<KPICard
label="Alertas"
value={
stats.outOfStockVariants > 0
? `${stats.outOfStockVariants} sin stock`
: 'Sin alertas'
}
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
/>
</div>
{/* Orders by state */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900">Pedidos por estado</h2>
<span className="text-sm text-gray-500">{totalOrders} total</span>
</div>
{totalOrders === 0 ? (
<div className="py-8 text-center text-gray-400 text-sm">
<p className="text-3xl mb-2">📋</p>
<p>No hay pedidos en el sistema</p>
</div>
) : (
<div>
{ordersByStateSorted.map(([state, count]) => (
<OrderStateBar
key={state}
state={state}
count={count}
total={totalOrders}
/>
))}
</div>
)}
</div>
{/* Quick actions */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Acciones rápidas</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ href: '/products/new', label: '+ Nuevo producto', icon: '🌿' },
{ href: '/orders', label: 'Ver pedidos', icon: '📦' },
{ href: '/inventory', label: 'Revisar stock', icon: '📊' },
{ href: '/customers', label: 'Clientes', icon: '👥' },
].map(({ href, label, icon }) => (
<a
key={href}
href={href}
className="flex items-center gap-2 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-[#2D6A4F] transition-colors text-sm font-medium text-gray-700"
>
<span>{icon}</span>
<span>{label}</span>
</a>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { paymentsApi, type PaymentTransaction } from '@/lib/api-client';
const STATUS_COLORS: Record<string, string> = {
succeeded: 'bg-green-100 text-green-700',
requires_payment: 'bg-yellow-100 text-yellow-700',
failed: 'bg-red-100 text-red-700',
refunded: 'bg-gray-100 text-gray-600',
chargeback: 'bg-red-100 text-red-800',
};
const PAGE_SIZE = 50;
export default function PaymentsPage() {
const [items, setItems] = useState<PaymentTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [msg, setMsg] = useState('');
useEffect(() => { const t = setTimeout(() => setDebounced(filter), 400); return () => clearTimeout(t); }, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await paymentsApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
setItems(data.items ?? []); setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
const handleRefund = async (id: string) => {
if (!confirm('¿Reembolsar este pago? Esta acción no se puede deshacer.')) return;
try {
await paymentsApi.refund(id);
setMsg('Reembolso procesado'); setTimeout(() => setMsg(''), 3000); load();
} catch (er) { alert(er instanceof Error ? er.message : 'Error al reembolsar'); }
};
const fmt = (cents: number) => `${(cents / 100).toFixed(2)}`;
const fmtDate = (d: string) => new Date(d).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' });
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pagos</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} transacción${total !== 1 ? 'es' : ''}` : ''}</p>
</div>
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<input type="text" placeholder="Buscar por ID de pago..." value={filter} onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">💳</span><span>Sin transacciones</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Importe</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Provider</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">ID Pago</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(txn => (
<tr key={txn.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{fmtDate(txn.createdAt)}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(txn.amountCents)}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[txn.status] ?? 'bg-gray-100 text-gray-600'}`}>
{txn.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{txn.provider}</td>
<td className="px-6 py-4 text-xs font-mono text-gray-400 max-w-[120px] truncate">{txn.providerPaymentId ?? '—'}</td>
<td className="px-6 py-4 text-right">
{txn.status === 'succeeded' && (
<button onClick={() => handleRefund(txn.id)}
className="text-sm text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition-colors">
Reembolsar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ProductEditor } from '@/features/products/components/ProductEditor';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ProductEditPage({ params }: PageProps) {
const { id } = await params;
return <ProductEditor productId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ProductEditor } from '@/features/products/components/ProductEditor';
export default function NewProductPage() {
return <ProductEditor />;
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import type { Product } from '@/types';
import { productsApi } from '@/lib/api-client';
const PAGE_SIZE = 20;
function formatPrice(cents?: number) {
if (cents == null) return '—';
return `${(cents / 100).toFixed(2)}`;
}
function StateBadge({ state }: { state: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: 'Activo', cls: 'bg-green-100 text-green-800' },
archived: { label: 'Archivado', cls: 'bg-gray-100 text-gray-600' },
draft: { label: 'Borrador', cls: 'bg-amber-100 text-amber-800' },
};
const { label, cls } = map[state] ?? { label: state, cls: 'bg-gray-100 text-gray-600' };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>
{label}
</span>
);
}
export default function ProductsPage() {
const router = useRouter();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// Reset page on search change
useEffect(() => {
setPage(0);
}, [debouncedSearch]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await productsApi.list({
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
q: debouncedSearch || undefined,
});
setProducts(data.items ?? []);
setTotal(data.items?.length ?? 0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [page, debouncedSearch]);
useEffect(() => { load(); }, [load]);
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
return (
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<p className="text-sm text-gray-500 mt-0.5">{total} productos</p>
</div>
<Link
href="/products/new"
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
>
+ Crear producto
</Link>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative max-w-md">
<input
type="search"
placeholder="Buscar por nombre..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Reintentar
</button>
</div>
) : products.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">
{debouncedSearch ? 'No hay productos para esta búsqueda' : 'No hay productos'}
</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
Producto
</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
Marca
</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
Estado
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{products.map((p) => (
<tr
key={p.id}
className="hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => router.push(`/products/${p.id}`)}
>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3">
{p.imageUrl ? (
<img
src={p.imageUrl}
alt={p.name}
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
🌿
</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
{p.name}
</p>
<p className="text-xs text-gray-400 truncate max-w-xs">
{p.description?.slice(0, 60) ?? p.slug}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{p.brand?.name ?? '—'}</p>
</td>
<td className="px-4 py-3">
<StateBadge state={p.state} />
</td>
<td className="px-4 py-3">
<span className="text-gray-300"></span>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">
Página {page + 1} de {totalPages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Anterior
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { promotionsApi } from '@/lib/api-client';
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
export default function PromotionsPage() {
const [items, setItems] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({
code: '', type: 'percent', value: '',
startsAt: new Date().toISOString().split('T')[0],
endsAt: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
usageLimit: '',
});
const load = useCallback(async () => {
setLoading(true);
try {
const d = await promotionsApi.list() as { items: Promo[] };
setItems(d.items ?? []);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
setMsg('');
try {
await promotionsApi.create({
code: form.code,
type: form.type,
value: parseInt(form.value, 10),
startsAt: new Date(form.startsAt).toISOString(),
endsAt: new Date(form.endsAt).toISOString(),
usageLimit: form.usageLimit ? parseInt(form.usageLimit, 10) : null,
});
setMsg('Promoción creada');
setShowForm(false);
load();
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error'); }
};
const toggleActive = async (code: string, currentActive: boolean) => {
try {
await promotionsApi.update(code, { active: !currentActive });
load();
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
};
const handleDelete = async (code: string) => {
if (!confirm(`¿Eliminar "${code}"?`)) return;
try { await promotionsApi.delete(code); 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">Promociones</h1>
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva promoción</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">Nueva promoción</h2>
<div className="grid grid-cols-2 gap-4">
{[['code','Código *','text'],['value','Valor *','number']].map(([k, label, t]) => (
<div key={k}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input type={t} value={(form as Record<string,string>)[k]} onChange={e => setForm({...form, [k]: 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" />
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="percent">Porcentaje</option><option value="fixed_amount">Cantidad fija</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Límite de uso</label>
<input type="number" value={form.usageLimit} onChange={e => setForm({...form, usageLimit: e.target.value})} placeholder="Sin límite" 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>
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
<input type="date" value={form.startsAt} onChange={e => setForm({...form, startsAt: 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" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
<input type="date" value={form.endsAt} onChange={e => setForm({...form, endsAt: 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" />
</div>
</div>
<div className="flex gap-3">
<button onClick={handleSave} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">Crear</button>
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">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> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> :
<table className="w-full">
<thead><tr className="bg-gray-50 border-b border-gray-200">
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].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="px-4 py-3"></th>
</tr></thead>
<tbody className="divide-y divide-gray-50">
{items.map(p => (
<tr key={p.code} className="hover:bg-gray-50">
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `${(p.value / 100).toFixed(2)}`}</td>
<td className="px-4 py-3.5">
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
<td className="px-4 py-3.5"><button onClick={() => handleDelete(p.code)} className="text-xs text-red-600 hover:underline">Eliminar</button></td>
</tr>
))}
</tbody>
</table>
}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { reviewsApi } from '@/lib/api-client';
interface Review {
id: string; productId: string; userId: string; orderId: string;
rating: number; title: string; body: string; status: string; createdAt: string;
}
const STATUS_LABELS: Record<string, string> = { pending: 'Pendiente', published: 'Publicada', rejected: 'Rechazada' };
const STATUS_CLS: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
published: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
};
function Stars({ n }: { n: number }) {
return (
<span className="text-amber-400 text-sm">
{'★'.repeat(n)}{'☆'.repeat(5 - n)}
</span>
);
}
export default function ReviewsPage() {
const [items, setItems] = useState<Review[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
const load = useCallback(async () => {
setLoading(true);
try {
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number };
setItems(d.items ?? []);
setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [filter]);
useEffect(() => { load(); }, [load]);
const moderate = async (id: string, status: 'published' | 'rejected') => {
try {
await reviewsApi.moderate(id, status);
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">
<div><h1 className="text-2xl font-bold text-gray-900">Reseñas</h1><p className="text-sm text-gray-500 mt-0.5">{total} reseñas pendientes de moderación</p></div>
<div className="flex gap-2">
{['', 'pending', 'published', 'rejected'].map(s => (
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1.5 rounded-lg text-xs font-medium ${filter === s ? 'bg-[#2D6A4F] text-white' : 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
{s ? STATUS_LABELS[s] : 'Todas'}
</button>
))}
</div>
</div>
<div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> :
items.map(r => (
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<Stars n={r.rating} />
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
{r.status === 'pending' && (
<div className="flex gap-3">
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg"> Publicar</button>
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg"> Rechazar</button>
</div>
)}
{r.status !== 'pending' && (
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
</button>
)}
</div>
))
}
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsApi, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings;
export default function SettingsPage() {
const [data, setData] = useState<FormData | null>(null);
const [form, setForm] = useState<FormData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
settingsApi.get().then(d => {
setData(d); setForm(d);
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
setSaving(true); setErr(''); setMsg('');
try {
const updated = await settingsApi.update(form);
setData(updated); setForm(updated);
setMsg('Cambios guardados correctamente');
setTimeout(() => setMsg(''), 4000);
} catch (er) {
setErr(er instanceof Error ? er.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const field = (key: keyof FormData, label: string, opts?: { type?: string; placeholder?: string; rows?: number }) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
{opts?.rows ? (
<textarea value={form?.[key] ?? ''} onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
rows={opts.rows} placeholder={opts.placeholder}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
) : (
<input type={opts?.type ?? 'text'} value={form?.[key] ?? ''}
onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
placeholder={opts?.placeholder} maxLength={key === 'contactAddress' ? 400 : 200}
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>
);
return (
<div className="p-8 space-y-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-3 rounded-xl border border-green-200">{msg}</div>}
{err && <div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-xl border border-red-200">{err}</div>}
{loading ? (
<div className="bg-white rounded-2xl border border-gray-200 p-12 flex items-center justify-center text-gray-400 text-sm">Cargando...</div>
) : (
<form onSubmit={handleSave} className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Información general</h2>
</div>
<div className="p-6 space-y-5">
{field('storeName', 'Nombre de la tienda', { placeholder: 'Mercado de Vida' })}
{field('storeTagline', 'Eslogan', { placeholder: 'Productos naturales y ecológicos' })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Contacto</h2>
</div>
<div className="p-6 space-y-5">
{field('contactEmail', 'Email de contacto', { type: 'email', placeholder: 'info@mercadodevida.es' })}
{field('contactPhone', 'Teléfono', { placeholder: '+34 600 000 000' })}
{field('contactAddress', 'Dirección', { placeholder: 'Calle ejemplo, ciudad', rows: 3 })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Redes sociales</h2>
</div>
<div className="p-6 space-y-5">
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Footer</h2>
</div>
<div className="p-6 space-y-5">
{field('footerText', 'Texto del pie de página', { placeholder: '© 2026 Mercado de Vida...', rows: 2 })}
</div>
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
<button type="submit" disabled={saving || !form}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
type Tab = 'zones' | 'methods';
// ── Zone helpers ──────────────────────────────────────────────────────────────────
function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () => void; onDelete: () => void }) {
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{zone.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{zone.country}</td>
<td className="px-6 py-4 text-sm text-gray-500">{zone.postalCodePrefix ?? <span className="italic">Todos</span>}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${zone.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{zone.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
</td>
</tr>
);
}
function ZoneForm({ zone, onSave, onCancel }: { zone?: ShippingZone; onSave: () => void; onCancel: () => void }) {
const [name, setName] = useState(zone?.name ?? '');
const [country, setCountry] = useState(zone?.country ?? '');
const [prefix, setPrefix] = useState(zone?.postalCodePrefix ?? '');
const [active, setActive] = useState(zone?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
if (zone) {
await shippingApi.updateZone(zone.id, { name, country, postalCodePrefix: prefix || null, active });
} else {
await shippingApi.createZone({ name, country, postalCodePrefix: prefix || null, active });
}
onSave(); onCancel();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<tr className="bg-green-50/50 border-b border-green-100">
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre zona"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input value={country} onChange={e => setCountry(e.target.value)} required placeholder="ES, FR..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="Ej: 28"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
<td className="px-4 py-3">
<div className="flex gap-1">
<button disabled={saving} onClick={handle}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={onCancel}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
</div>
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
</td>
</tr>
);
}
// ── Method helpers ───────────────────────────────────────────────────────────────
function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdit: () => void; onDelete: () => void }) {
const fmt = (cents: number) => `${(cents / 100).toFixed(2)}`;
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{method.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{method.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
</td>
</tr>
);
}
function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]; method?: ShippingMethod; onSave: () => void; onCancel: () => void }) {
const [name, setName] = useState(method?.name ?? '');
const [zoneId, setZoneId] = useState(method?.zoneId ?? zones[0]?.id ?? '');
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
const [active, setActive] = useState(method?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
const baseCostCents = Math.round(parseFloat(cost) * 100);
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
if (method) {
await shippingApi.updateMethod(method.id, { name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
} else {
await shippingApi.createMethod({ zoneId, name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
}
onSave(); onCancel();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<tr className="bg-green-50/50 border-b border-green-100">
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={zoneId} onChange={e => setZoneId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
</select></td>
<td className="px-4 py-3"><input type="number" step="0.01" value={cost} onChange={e => setCost(e.target.value)} required placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
<td className="px-4 py-3">
<div className="flex gap-1">
<button disabled={saving} onClick={handle}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={onCancel}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
</div>
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
</td>
</tr>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────────
export default function ShippingPage() {
const [tab, setTab] = useState<Tab>('zones');
const [zones, setZones] = useState<ShippingZone[]>([]);
const [methods, setMethods] = useState<ShippingMethod[]>([]);
const [loading, setLoading] = useState(true);
const [editingZone, setEditingZone] = useState<ShippingZone | null>(null);
const [editingMethod, setEditingMethod] = useState<ShippingMethod | null>(null);
const [showZoneForm, setShowZoneForm] = useState(false);
const [showMethodForm, setShowMethodForm] = useState(false);
const [msg, setMsg] = useState('');
const loadZones = useCallback(async () => {
try { const d = await shippingApi.listZones(); setZones(d.items ?? []); }
catch { /* silent */ }
}, []);
const loadMethods = useCallback(async () => {
try { const d = await shippingApi.listMethods(); setMethods(d.items ?? []); }
catch { /* silent */ }
}, []);
const load = useCallback(async () => {
setLoading(true);
await Promise.all([loadZones(), loadMethods()]);
setLoading(false);
}, [loadZones, loadMethods]);
useEffect(() => { load(); }, [load]);
const handleDeleteZone = async (id: string) => {
if (!confirm('¿Eliminar esta zona y todos sus métodos?')) return;
try { await shippingApi.deleteZone(id); setMsg('Zona eliminada'); setTimeout(() => setMsg(''), 3000); loadZones(); }
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
};
const handleDeleteMethod = async (id: string) => {
if (!confirm('¿Eliminar este método de envío?')) return;
try { await shippingApi.deleteMethod(id); setMsg('Método eliminado'); setTimeout(() => setMsg(''), 3000); loadMethods(); }
catch (er) { alert(er instanceof Error ? er.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">Envíos</h1>
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{([['zones', 'Zonas de envío'], ['methods', 'Métodos de envío']] as [Tab, string][]).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : tab === 'zones' ? (
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Zonas ({zones.length})</h2>
<button onClick={() => { setShowZoneForm(true); setEditingZone(null); }}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
+ Nueva zona
</button>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">País</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">CP prefijo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{showZoneForm && !editingZone && (
<ZoneForm onSave={loadZones} onCancel={() => setShowZoneForm(false)} />
)}
{editingZone && (
<ZoneForm zone={editingZone} onSave={() => { setEditingZone(null); loadZones(); }} onCancel={() => setEditingZone(null)} />
)}
{zones.length === 0 && !showZoneForm ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-400 text-sm">Sin zonas de envío</td></tr>
) : zones.map(z => (
<ZoneRow key={z.id} zone={z} onEdit={() => { setEditingZone(z); setShowZoneForm(false); }}
onDelete={() => handleDeleteZone(z.id)} />
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Métodos ({methods.length})</h2>
<button onClick={() => { setShowMethodForm(true); setEditingMethod(null); }}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
disabled={zones.length === 0}>
+ Nuevo método
</button>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{showMethodForm && !editingMethod && (
<MethodForm zones={zones} onSave={loadMethods} onCancel={() => setShowMethodForm(false)} />
)}
{editingMethod && (
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
)}
{methods.length === 0 && !showMethodForm ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
</td></tr>
) : methods.map(m => (
<MethodRow key={m.id} method={m} onEdit={() => { setEditingMethod(m); setShowMethodForm(false); }}
onDelete={() => handleDeleteMethod(m.id)} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { taxApi, type TaxRate } from '@/lib/api-client';
export default function TaxRatesPage() {
const [rates, setRates] = useState<TaxRate[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editRate, setEditRate] = useState('');
const [editActive, setEditActive] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try { const d = await taxApi.list(); setRates(d.items ?? []); }
catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const startEdit = (r: TaxRate) => {
setEditing(r.id); setEditName(r.name); setEditRate(String(r.ratePercent)); setEditActive(r.active);
};
const handleSave = async (id: string) => {
setSaving(true);
try {
await taxApi.update(id, { name: editName, ratePercent: parseFloat(editRate), active: editActive });
setEditing(null); setMsg('Tipo impositivo actualizado'); setTimeout(() => setMsg(''), 3000); load();
} catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
finally { setSaving(false); }
};
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
return (
<div className="p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tipos impositivos (IVA)</h1>
<p className="text-sm text-gray-500 mt-0.5">Configura los tipos de IVA aplicables a los productos.</p>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b bg-gray-50">
<h2 className="text-base font-semibold text-gray-800">IVA en España (ES)</h2>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tipo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tasa</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rates.map(r => (
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
{editing === r.id ? (
<>
<td className="px-4 py-3">
<input value={editName} onChange={e => setEditName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3 text-sm text-gray-500">{r.appliesTo}</td>
<td className="px-4 py-3">
<input type="number" step="0.01" value={editRate} onChange={e => setEditRate(e.target.value)}
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3">
<select value={String(editActive)} onChange={e => setEditActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select>
</td>
<td className="px-4 py-3">
<div className="flex gap-1 justify-end">
<button disabled={saving} onClick={() => handleSave(r.id)}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={() => setEditing(null)}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-gray-50">Cancelar</button>
</div>
</td>
</>
) : (
<>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{r.name}</td>
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{r.appliesTo}</td>
<td className="px-6 py-4 text-sm font-bold text-gray-800">{fmt(r)}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{r.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => startEdit(r)}
className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 transition-colors">
Editar
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="bg-amber-50 rounded-xl border border-amber-200 px-5 py-4">
<p className="text-sm text-amber-800">
<strong>España:</strong> IVA General 21%, IVA Reducido 10%, IVA Superreducido 4%. Los tipos se aplican a los precios sin IVA (netos) del producto.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { adminUsersApi } from '@/lib/api-client';
interface AdminUser { id: string; email: string; role: string; createdAt: string; }
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('editor');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try { await adminUsersApi.create({ email, password, role }); onCreated(); onClose(); }
catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<form onSubmit={handle} className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
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><label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8}
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><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
<select value={role} onChange={e => setRole(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">
<option value="editor">Editor</option><option value="admin">Admin</option></select></div>
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Creando...' : 'Crear usuario'}</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
</div>
</form>
);
}
function EditForm({ user, onClose, onSaved }: { user: AdminUser; onClose: () => void; onSaved: () => void }) {
const [role, setRole] = useState(user.role);
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
const data: { role?: string; password?: string } = { role };
if (password) data.password = password;
await adminUsersApi.update(user.id, data); onSaved(); onClose();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<form onSubmit={handle} className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={user.email} disabled
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
<select value={role} onChange={e => setRole(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">
<option value="admin">Admin</option><option value="editor">Editor</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Nueva contraseña</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} minLength={8} placeholder="Dejar vacío para no cambiar"
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>
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 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...' : 'Guardar'}</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
</div>
</form>
);
}
const PAGE_SIZE = 20;
const ROLE_COLORS: Record<string, string> = { admin: 'bg-purple-100 text-purple-700', editor: 'bg-amber-100 text-amber-700', customer: 'bg-blue-100 text-blue-700' };
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterRole, setFilterRole] = useState('');
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [msg, setMsg] = useState('');
useEffect(() => { const t = setTimeout(() => setDebounced(search), 400); return () => clearTimeout(t); }, [search]);
useEffect(() => { setPage(0); }, [debounced, filterRole]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await adminUsersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, role: filterRole || undefined, q: debounced || undefined });
setUsers(data.items ?? []); setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); }
}, [page, debounced, filterRole]);
useEffect(() => { load(); }, [load]);
const handleDelete = async (id: string) => {
if (!confirm('¿Eliminar este usuario? No se puede deshacer.')) return;
try { await adminUsersApi.delete(id); setMsg('Usuario eliminado'); setTimeout(() => setMsg(''), 3000); load(); }
catch (er) { alert(er instanceof Error ? er.message : 'Error al eliminar'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-gray-900">Usuarios backoffice</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} usuario${total !== 1 ? 's' : ''}` : ''}</p></div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
+ Nuevo usuario</button>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<input type="text" placeholder="Buscar por email..." value={search} onChange={e => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="">Todos los roles</option>
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
</select>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">🔐</span><span>No hay usuarios backoffice</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Rol</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Creado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map(u => (
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-900">{u.email}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] ?? 'bg-gray-100 text-gray-600'}`}>
{u.role}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">{new Date(u.createdAt).toLocaleDateString('es-ES')}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setEditing(u)}
className="p-2 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => handleDelete(u.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
Mostrando {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
Anterior
</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
{showCreate && (
<Modal title="Nuevo usuario backoffice" onClose={() => setShowCreate(false)}>
<CreateForm onClose={() => setShowCreate(false)} onCreated={() => { setMsg('Usuario creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); }} />
</Modal>
)}
{editing && (
<Modal title="Editar usuario" onClose={() => setEditing(null)}>
<EditForm user={editing} onClose={() => setEditing(null)} onSaved={() => { setMsg('Usuario actualizado'); setTimeout(() => setMsg(''), 3000); load(); }} />
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Catch-all proxy: forwards ALL requests to the backend API.
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
*
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
* so they are NOT served by this handler.
*/
export async function GET(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}`, {
headers: { Cookie: cookies },
});
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const setCookie = backendRes.headers.get('set-cookie');
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
if (setCookie) {
resp.headers.set(
'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
);
}
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PATCH(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PUT(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function DELETE(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'DELETE',
headers: { Cookie: cookies },
});
return NextResponse.json({ ok: backendRes.ok }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Strip the Secure flag from the backend's Set-Cookie so the browser
* (which connects over HTTP) actually stores the session cookie.
* Also drop SameSite=Lax to avoid browser restrictions.
*/
function makeLocalhostCompatible(cookie: string): string {
return cookie
.replace(/;\s*Secure/gi, '')
.replace(/;\s*SameSite=Lax/gi, '')
.trim();
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, password } = body;
const backendRes = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await backendRes.json();
if (!backendRes.ok) {
return NextResponse.json(data, { status: backendRes.status });
}
const setCookie = backendRes.headers.get('set-cookie');
const response = NextResponse.json(data, { status: 200 });
if (setCookie) {
response.headers.set('Set-Cookie', makeLocalhostCompatible(setCookie));
}
return response;
} catch {
return NextResponse.json(
{ statusCode: 500, code: 'SERVER_ERROR', message: 'Error del servidor' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function POST(req: NextRequest) {
try {
const cookies = req.headers.get('cookie') ?? '';
await fetch(`${API}/auth/logout`, {
method: 'POST',
headers: { Cookie: cookies },
});
} catch {
// Best-effort
}
const response = NextResponse.json({ ok: true });
response.cookies.delete('mdv_session');
return response;
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function GET(req: NextRequest) {
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/auth/me`, {
headers: { Cookie: cookies },
});
if (!backendRes.ok) return NextResponse.json({ user: null });
return NextResponse.json(await backendRes.json());
} catch {
return NextResponse.json({ user: null });
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
{ status: 400 },
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
}
// Unique filename
const ext = file.name.split('.').pop() ?? 'jpg';
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filePath = path.join(uploadDir, filename);
await mkdir(uploadDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const url = `/uploads/${filename}`;
return NextResponse.json({ url, filename, size: file.size });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
}
}

View File

@@ -0,0 +1,51 @@
@import "tailwindcss";
@theme {
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-primary-light: #40916C;
--color-secondary: #F5F0E8;
--color-accent: #E76F51;
--color-text: #111827;
--color-muted: #6B7280;
--color-border: #E5E7EB;
--color-bg: #F9FAFB;
--color-surface: #FFFFFF;
--color-danger: #DC2626;
--color-warning: #D97706;
--color-success: #059669;
--font-sans: "Inter", system-ui, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
}
:root {
--background: #F9FAFB;
--foreground: #111827;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9CA3AF;
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
default: 'MercadoDeVida Admin',
template: '%s | MercadoDeVida Admin',
},
robots: { index: false, follow: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning>
<body>{children}</body>
</html>
);
}