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,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
project/apps/admin/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
outputFileTracingRoot: __dirname,
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**' },
],
},
};
export default nextConfig;

6781
project/apps/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "@mercadodevida/admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3004",
"build": "next build",
"start": "next start --port 3004",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

View File

@@ -0,0 +1,68 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AuthUser, Role } from '@/types';
import { authApi } from '@/lib/api-client';
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// Load session on mount
useEffect(() => {
authApi
.me()
.then((data) => {
if ('id' in data) {
setUser({ id: data.id, email: data.email, role: data.role as Role });
}
})
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
try {
const data = await authApi.login(email, password);
// The backend sets the session cookie via Set-Cookie header.
// We also set it client-side for immediate access.
setUser({ id: data.id, email: data.email, role: data.role as Role });
return { ok: true };
} catch (err: unknown) {
const msg =
err instanceof Error
? (err as { message?: string }).message ?? 'Error de login'
: 'Error de login';
return { ok: false, error: msg };
}
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch {
// ignore
}
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,395 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import type { Product, Brand, Category } from '@/types';
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
import { ImagesSection } from './sections/ImagesSection';
import { InventorySection } from './sections/InventorySection';
import { PricingSection } from './sections/PricingSection';
interface ProductEditorProps {
productId?: string;
}
const ATTRIBUTE_LABELS: Record<string, string> = {
bio: '🌿 Bio',
'comercio-justo': '⚖️ Comercio Justo',
congelado: '❄️ Congelado',
'cruelty-free': '🐰 Cruelty Free',
'de-temporada': '🍂 De Temporada',
demeter: '🌱 Demeter',
'fruta-verdura': '🥕 Fruta y Verdura',
keto: '🥑 Keto',
kosher: '✡️ Kosher',
'low-carb': '🍖 Low Carb',
'raw-food': '🥗 Raw Food',
'sin-azucar': '🚫 Sin Azúcar',
'sin-gluten': '🌾 Sin Gluten',
'sin-lactosa': '🥛 Sin Lactosa',
vegano: '🌱 Vegano',
'zero-waste': '♻️ Zero Waste',
};
const CHANNEL_OPTIONS = [
{ value: 'all', label: 'Todos los canales' },
{ value: 'online', label: 'Solo online' },
{ value: 'offline', label: 'Solo offline' },
] as const;
function slugify(text: string): string {
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
export function ProductEditor({ productId }: ProductEditorProps) {
const router = useRouter();
const isCreate = !productId;
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!isCreate);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [desc, setDesc] = useState('');
const [brandId, setBrandId] = useState('');
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const [channels, setChannels] = useState<'online' | 'offline' | 'all'>('all');
const [featured, setFeatured] = useState(false);
const [attributes, setAttributes] = useState<string[]>([]);
const [state, setState] = useState('active');
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDesc, setSeoDesc] = useState('');
const [seoDescManual, setSeoDescManual] = useState(false);
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
brandsApi.list().then(({ items }) => setBrands(items ?? [])).catch(() => {});
categoriesApi.list().then((data) => {
const tree = (data as { items?: Category[] }).items ?? [];
const flat = (cats: Category[]): Category[] => cats.flatMap(c => [c, ...flat(c.children ?? [])]);
setCategories(flat(tree));
}).catch(() => {});
}, []);
const snapRef = useRef('');
const dirtyRef = useRef(false);
const getSnap = useCallback(() => JSON.stringify({
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc,
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc]);
useEffect(() => {
if (!productId) { setLoading(false); return; }
productsApi.get(productId).then((p: Product) => {
setName(p.name); setSlug(p.slug); setDesc(p.description ?? '');
setBrandId(p.brandId ?? ''); setCategoryIds(p.categoryIds ?? []);
setChannels((p as any).channels ?? 'all');
setFeatured((p as any).featured ?? false);
setAttributes((p as any).attributes ?? []);
setState(p.state);
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
snapRef.current = getSnap();
setLoading(false);
}).catch(() => { setError('No se pudo cargar el producto'); setLoading(false); });
}, [productId, getSnap]);
useEffect(() => {
if (loading) return;
dirtyRef.current = getSnap() !== snapRef.current;
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, loading, getSnap]);
useEffect(() => {
const h = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } };
window.addEventListener('beforeunload', h);
return () => window.removeEventListener('beforeunload', h);
}, []);
const handleNameChange = (v: string) => {
setName(v);
if (!slugManual) setSlug(slugify(v));
if (!seoTitleManual) setSeoTitle(v);
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
};
const handleSave = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const payload = {
name, slug,
description: desc || undefined,
brandId: brandId || undefined,
categoryIds,
channels,
featured,
attributes,
state,
seoTitle: seoTitle || undefined,
seoDescription: seoDesc || undefined,
};
let saved: Product;
if (isCreate) saved = await productsApi.create(payload);
else saved = await productsApi.update(productId, payload);
snapRef.current = getSnap();
dirtyRef.current = false;
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
if (isCreate) router.push(`/products/${saved.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const toggleAttr = (key: string) => {
setAttributes(prev => prev.includes(key) ? prev.filter(a => a !== key) : [...prev, key]);
};
const saveState = async (s: string) => {
setState(s);
if (productId) {
try {
await productsApi.setState(productId, s as 'active' | 'archived');
setSuccess(`Estado actualizado a: ${s}`);
} catch {
setError('Error al cambiar estado');
}
}
};
if (loading) return (
<div className="p-8 flex justify-center">
<div className="h-6 w-6 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
</div>
);
return (
<div className="p-8 max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div>
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1"> Productos</button>
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
</div>
<button onClick={handleSave} disabled={saving}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
</button>
</div>
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
{/* Tabs */}
<div className="hidden md:flex border-b border-gray-200 mb-8">
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
<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'
}`}>
{t === 'general' ? 'General' : t === 'pricing' ? 'Precios' : t === 'inventory' ? 'Inventario' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
</button>
))}
</div>
{/* ── GENERAL ── */}
{tab === 'general' && (
<section className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
placeholder="Ej: Almendras Crudas Ecológicas"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{slugManual ? 'editado manualmente' : 'auto-generado'}
</span>
</div>
<input type="text" value={slug}
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
placeholder="Descripción detallada del producto..."
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" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
<select value={brandId} onChange={e => setBrandId(e.target.value)}
className="w-full 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="">Sin marca</option>
{brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Canal de venta</label>
<select value={channels} onChange={e => setChannels(e.target.value as typeof channels)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<label className="flex items-center gap-2 mb-3">
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className="text-sm font-semibold text-gray-900"> Producto destacado</span>
<span className="text-xs text-gray-400">(aparece en la home)</span>
</label>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Categorías</label>
</div>
<div className="border border-gray-200 rounded-xl p-3 space-y-2 max-h-52 overflow-y-auto">
{categories.map(cat => (
<label key={cat.id} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={categoryIds.includes(cat.id)}
onChange={e => {
if (e.target.checked) setCategoryIds(prev => [...prev, cat.id]);
else setCategoryIds(prev => prev.filter(id => id !== cat.id));
}}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className={`text-sm ${cat.parentId ? 'text-gray-500' : 'font-medium text-gray-700'}`}>
{cat.parentId ? `${cat.name}` : cat.name}
</span>
</label>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Atributos</label>
<span className="text-xs text-gray-400">{attributes.length} / 16</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
<label key={key}
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
attributes.includes(key)
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
: 'border-gray-200 hover:border-gray-300 text-gray-600'
}`}>
<input type="checkbox" checked={attributes.includes(key)}
onChange={() => toggleAttr(key)} className="hidden" />
{label}
</label>
))}
</div>
</div>
</section>
)}
{/* ── PRICING ── */}
{tab === 'pricing' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para configurar precios.
</div>
) : (
<PricingSection productId={productId} />
)}
</section>
)}
{/* ── INVENTORY ── */}
{tab === 'inventory' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar inventario.
</div>
) : (
<InventorySection productId={productId} />
)}
</section>
)}
{/* ── IMAGES ── */}
{tab === 'images' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para subir imágenes.
</div>
) : (
<ImagesSection productId={productId} />
)}
</section>
)}
{/* ── SEO ── */}
{tab === 'seo' && (
<section className="space-y-6">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
<p>El título y descripción SEO se usan en Google. Si están vacíos, se usan automáticamente.</p>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Título SEO (Google)</label>
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoTitleManual ? 'editado manualmente' : 'copiado del nombre'}
</span>
</div>
<input type="text" value={seoTitle}
onChange={e => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
maxLength={60}
placeholder="Título para Google (max 60 caracteres)"
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>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Descripción SEO (Google)</label>
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoDescManual ? 'editada manualmente' : 'auto-generada'}
</span>
</div>
<textarea value={seoDesc}
onChange={e => { setSeoDescManual(true); setSeoDesc(e.target.value); }}
rows={3} maxLength={160}
placeholder="Descripción para Google (max 160 caracteres)"
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" />
<div className="mt-1 text-xs text-gray-400">{seoDesc.length}/160</div>
</div>
</section>
)}
{/* ── PUBLISH ── */}
{tab === 'publish' && (
<section className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
<div className="grid grid-cols-3 gap-3">
{[['draft', 'Borrador', 'gray'], ['active', 'Activo', 'green'], ['archived', 'Archivado', 'amber']].map(([s, label, color]) => (
<button key={s} onClick={() => saveState(s as string)}
className={`px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all ${
state === s
? color === 'green' ? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]' : color === 'amber' ? 'border-amber-400 bg-amber-50 text-amber-700' : 'border-gray-400 bg-gray-100 text-gray-700'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}>
{label}
</button>
))}
</div>
</div>
<div className="p-5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600 space-y-2">
<div className="flex justify-between"><span>Borrador</span><span>No visible en la tienda</span></div>
<div className="flex justify-between"><span>Activo</span><span>Visible y comprable online</span></div>
<div className="flex justify-between"><span>Archivado</span><span>Oculto pero conservado</span></div>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { productsApi } from '@/lib/api-client';
import type { ProductImage } from '@/types';
interface ImagesSectionProps {
productId: string;
}
export function ImagesSection({ productId }: ImagesSectionProps) {
const [images, setImages] = useState<ProductImage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [urlInput, setUrlInput] = useState('');
const [savingUrl, setSavingUrl] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
try {
const p = await productsApi.get(productId);
setImages(p.images ?? []);
} catch {
setError('Error al cargar imágenes');
} finally {
setLoading(false);
}
}, [productId]);
useEffect(() => { load(); }, [load]);
const addImageByUrl = async (url: string) => {
if (!url.trim()) return;
setSavingUrl(true);
try {
const p = await productsApi.update(productId, {});
// Attach via images array — for now use the attach endpoint
await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({ url: url.trim(), altText: '', role: 'gallery' }),
});
setUrlInput('');
load();
} catch {
setError('Error al añadir imagen');
} finally {
setSavingUrl(false);
}
};
const uploadFile = async (file: File) => {
setUploading(true);
setError('');
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? 'Error al subir');
}
const { url } = await res.json() as { url: string };
await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({ url, altText: '', role: 'gallery' }),
});
load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al subir');
} finally {
setUploading(false);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
uploadFile(file);
}
};
const setMain = async (imageId: string) => {
// Reorder: put this image first
await fetch(`/api/products/${productId}/images/reorder`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({
items: [
{ imageId, position: 0 },
...images.filter(i => i.id !== imageId).map((img, idx) => ({ imageId: img.id, position: idx + 1 })),
],
}),
});
load();
};
const deleteImage = async (imageId: string) => {
await fetch(`/api/products/${productId}/images/${imageId}`, { method: 'DELETE', credentials: 'include' });
load();
};
if (!productId) {
return <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar imágenes.
</div>;
}
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;
return (
<div className="space-y-5">
{/* Upload / URL / Drag&drop */}
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addImageByUrl(urlInput)}
placeholder="Pega una URL de imagen..."
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={() => addImageByUrl(urlInput)}
disabled={savingUrl || !urlInput.trim()}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{savingUrl ? 'Añadiendo...' : 'Añadir URL'}
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-5 py-2.5 border border-gray-300 hover:border-[#2D6A4F] text-gray-700 text-sm font-medium rounded-xl transition-colors"
>
{uploading ? 'Subiendo...' : '📤 Subir imagen'}
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileInput} />
</div>
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragOver ? 'border-[#2D6A4F] bg-[#2D6A4F]/5' : 'border-gray-200'
}`}
>
<p className="text-gray-400 text-sm">
🖼 Arrastra imágenes aquí para añadirlas al producto
</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>
)}
{/* Gallery */}
{images.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm border border-dashed border-gray-300 rounded-xl">
No hay imágenes para este producto
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((img, idx) => (
<div key={img.id} className="relative group">
<img
src={img.url}
alt={img.altText ?? img.url}
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
/>
{/* Main badge */}
{idx === 0 && (
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
Principal
</span>
)}
{/* Actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-2">
{idx !== 0 && (
<button
onClick={() => setMain(img.id)}
className="px-2 py-1 bg-white text-gray-800 text-xs rounded-lg hover:bg-gray-100"
>
Principal
</button>
)}
<button
onClick={() => deleteImage(img.id)}
className="p-2 bg-white text-red-600 rounded-lg hover:bg-red-50"
title="Eliminar"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,459 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
import type { ProductVariant, VariantPrice, StockAvailability } from '@/types';
interface VariantRow {
variant: ProductVariant;
price: VariantPrice | null;
stock: StockAvailability | null;
loadingStock: boolean;
loadingPrice: boolean;
editingStock: boolean;
editingPrice: boolean;
stockValue: string;
priceValue: string;
vatRate: 'general' | 'reduced';
}
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
function StockStatusBadge({ available, quantity }: { available: boolean; quantity: number }) {
if (!available || quantity === 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
Sin stock
</span>
);
}
if (quantity < 5) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
Bajo stock ({quantity})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
En stock ({quantity})
</span>
);
}
interface InventorySectionProps {
productId: string;
}
export function InventorySection({ productId }: InventorySectionProps) {
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [loadingVariants, setLoadingVariants] = useState(true);
const [variantsError, setVariantsError] = useState('');
const [rows, setRows] = useState<Record<string, VariantRow>>({});
const [savingVariant, setSavingVariant] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<Record<string, string>>({});
// Load variants
useEffect(() => {
if (!productId) return;
setLoadingVariants(true);
productsApi.getVariants(productId)
.then(({ items }) => {
setVariants(items ?? []);
const initial: Record<string, VariantRow> = {};
for (const variant of items ?? []) {
initial[variant.id] = {
variant,
price: null,
stock: null,
loadingStock: true,
loadingPrice: true,
editingStock: false,
editingPrice: false,
stockValue: '',
priceValue: '',
vatRate: 'general',
};
}
setRows(initial);
setLoadingVariants(false);
})
.catch(() => {
setVariantsError('No se pudieron cargar las variantes');
setLoadingVariants(false);
});
}, [productId]);
// Load stock and price for each variant
useEffect(() => {
for (const variant of variants) {
// Stock
inventoryApi.getAvailability(variant.id)
.then((stock) => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return {
...prev,
[variant.id]: {
...current,
stock,
loadingStock: false,
stockValue: String(stock.availableQuantity),
},
};
});
})
.catch(() => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return { ...prev, [variant.id]: { ...current, loadingStock: false } };
});
});
// Price
pricingApi.getVariantPrice(variant.id)
.then((price) => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return {
...prev,
[variant.id]: {
...current,
price,
loadingPrice: false,
priceValue: String(price.netUnitAmountCents),
vatRate: price.vatRate,
},
};
});
})
.catch(() => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return { ...prev, [variant.id]: { ...current, loadingPrice: false } };
});
});
}
}, [variants]);
const startEditStock = (variantId: string) => {
setRows((prev) => ({
...prev,
[variantId]: { ...prev[variantId], editingStock: true },
}));
};
const startEditPrice = (variantId: string) => {
setRows((prev) => ({
...prev,
[variantId]: { ...prev[variantId], editingPrice: true },
}));
};
const cancelEditStock = (variantId: string) => {
const r = rows[variantId];
setRows((prev) => ({
...prev,
[variantId]: { ...r, editingStock: false, stockValue: String(r.stock?.availableQuantity ?? 0) },
}));
};
const cancelEditPrice = (variantId: string) => {
const r = rows[variantId];
setRows((prev) => ({
...prev,
[variantId]: {
...r,
editingPrice: false,
priceValue: String(r.price?.netUnitAmountCents ?? 0),
vatRate: r.price?.vatRate ?? 'general',
},
}));
};
const saveStock = async (variantId: string) => {
const r = rows[variantId];
const qty = parseInt(r.stockValue, 10);
if (isNaN(qty) || qty < 0) return;
setSavingVariant(variantId);
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
try {
const result = await inventoryApi.setStock(variantId, qty);
setRows((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
stock: {
available: result.available > 0,
availableQuantity: result.available,
},
editingStock: false,
},
}));
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSavingVariant(null);
}
};
const savePrice = async (variantId: string) => {
const r = rows[variantId];
const cents = parseInt(r.priceValue, 10);
if (isNaN(cents) || cents < 0) return;
setSavingVariant(variantId);
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
try {
const result = await pricingApi.setVariantPrice(variantId, cents, r.vatRate);
setRows((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
price: result,
editingPrice: false,
},
}));
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSavingVariant(null);
}
};
if (loadingVariants) {
return (
<div className="flex items-center gap-3 p-8 text-gray-400 text-sm">
<div className="h-4 w-4 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
Cargando inventario...
</div>
);
}
if (variantsError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{variantsError}
</div>
);
}
if (variants.length === 0) {
return (
<div className="p-8 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">Este producto no tiene variantes</p>
<p className="text-gray-400 text-xs mt-1">
Las variantes se crean desde la pestaña Publicar
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border border-gray-200">
<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">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">Precio neto</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</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>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{variants.map((variant) => {
const r = rows[variant.id];
if (!r) return null;
const grossPrice = r.price
? (r.price.netUnitAmountCents * (r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
: null;
return (
<tr key={variant.id} className="hover:bg-gray-50/50 transition-colors">
{/* SKU */}
<td className="px-4 py-3 font-mono text-xs text-gray-600">{variant.sku}</td>
{/* EAN */}
<td className="px-4 py-3 font-mono text-xs text-gray-500">{variant.ean ?? '—'}</td>
{/* Precio */}
<td className="px-4 py-3">
{r.loadingPrice ? (
<span className="text-gray-300"></span>
) : r.editingPrice ? (
<div className="flex items-center gap-1">
<span className="text-gray-400"></span>
<input
type="number"
min={0}
value={r.priceValue}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: { ...prev[variant.id], priceValue: e.target.value },
}))
}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{r.price ? formatCents(r.price.netUnitAmountCents) : '—'}
</span>
{r.price && (
<button
onClick={() => startEditPrice(variant.id)}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar precio"
>
</button>
)}
</div>
)}
</td>
{/* IVA */}
<td className="px-4 py-3">
{r.editingPrice ? (
<select
value={r.vatRate}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: {
...prev[variant.id],
vatRate: e.target.value as 'general' | 'reduced',
},
}))
}
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none"
>
<option value="general">21% (general)</option>
<option value="reduced">10% (reducido)</option>
</select>
) : (
<span className="text-xs text-gray-500">
{r.price?.vatRate === 'reduced' ? '10%' : '21%'}
</span>
)}
</td>
{/* Stock */}
<td className="px-4 py-3">
{r.loadingStock ? (
<span className="text-gray-300"></span>
) : r.editingStock ? (
<div className="flex items-center gap-1">
<input
type="number"
min={0}
value={r.stockValue}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: { ...prev[variant.id], stockValue: e.target.value },
}))
}
className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={() => saveStock(variant.id)}
disabled={savingVariant === variant.id}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{savingVariant === variant.id ? '...' : 'OK'}
</button>
<button
onClick={() => cancelEditStock(variant.id)}
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">
{r.stock?.availableQuantity ?? '—'}
</span>
<button
onClick={() => startEditStock(variant.id)}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar stock"
>
</button>
</div>
)}
</td>
{/* Estado + acciones */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<StockStatusBadge
available={r.stock?.available ?? false}
quantity={r.stock?.availableQuantity ?? 0}
/>
{r.editingPrice && (
<button
onClick={() => savePrice(variant.id)}
disabled={savingVariant === variant.id}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{savingVariant === variant.id ? '...' : 'OK'}
</button>
)}
{r.editingPrice && (
<button
onClick={() => cancelEditPrice(variant.id)}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
)}
{saveMsg[variant.id] && !r.editingStock && !r.editingPrice && (
<span className={`text-xs ${saveMsg[variant.id].startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>
{saveMsg[variant.id]}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400">
* Precio con IVA:{' '}
{variants[0] && rows[variants[0].id]?.price
? formatCents(
Math.round(
rows[variants[0].id].price!.netUnitAmountCents *
(rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
),
)
: '—'}
</p>
</div>
);
}

View File

@@ -0,0 +1,261 @@
'use client';
import { useState, useEffect } from 'react';
import { productsApi, pricingApi } from '@/lib/api-client';
import type { ProductVariant, VariantPrice } from '@/types';
const VAT_GENERAL = 1.21;
const VAT_REDUCED = 1.10;
function fmt(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
return Math.round(netCents * (vatRate === 'general' ? VAT_GENERAL : VAT_REDUCED));
}
function calcMarginBruto(grossCents: number, costCents: number): number {
if (grossCents === 0) return 0;
return Math.round(((grossCents - costCents) / grossCents) * 100);
}
interface PricingSectionProps {
productId: string;
}
export function PricingSection({ productId }: PricingSectionProps) {
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [loadingVariants, setLoadingVariants] = useState(true);
const [loadingPrices, setLoadingPrices] = useState(true);
const [prices, setPrices] = useState<Record<string, VariantPrice>>({});
const [saving, setSaving] = useState<string | null>(null);
const [msg, setMsg] = useState<Record<string, string>>({});
// Edit state per variant
const [net, setNet] = useState<Record<string, string>>({});
const [offer, setOffer] = useState<Record<string, string>>({});
const [cost, setCost] = useState<Record<string, string>>({});
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced'>>({});
useEffect(() => {
if (!productId) { setLoadingVariants(false); return; }
productsApi.getVariants(productId)
.then(({ items }) => {
setVariants(items ?? []);
setLoadingVariants(false);
})
.catch(() => setLoadingVariants(false));
}, [productId]);
useEffect(() => {
if (variants.length === 0) { setLoadingPrices(false); return; }
let done = 0;
for (const v of variants) {
pricingApi.getVariantPrice(v.id)
.then((p) => {
setPrices(prev => ({ ...prev, [v.id]: p }));
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
})
.catch(() => {
setNet(prev => ({ ...prev, [v.id]: '0' }));
setOffer(prev => ({ ...prev, [v.id]: '' }));
setCost(prev => ({ ...prev, [v.id]: '' }));
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
})
.finally(() => {
done++;
if (done >= variants.length) setLoadingPrices(false);
});
}
}, [variants]);
const savePrice = async (variantId: string) => {
const netCents = parseInt(net[variantId] ?? '0', 10);
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
if (isNaN(netCents) || netCents < 0) return;
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
setSaving(variantId);
setMsg(prev => ({ ...prev, [variantId]: '' }));
try {
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
if (offerCentsVal !== null) {
// set offer via separate update
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
} else {
setPrices(prev => ({ ...prev, [variantId]: updated }));
}
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setMsg(prev => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSaving(null);
}
};
if (loadingVariants) return <div className="p-8 text-gray-400 text-sm">Cargando precios...</div>;
if (variants.length === 0) {
return (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Este producto no tiene variantes. Las variantes se crean desde la pestaña Publicar.
</div>
);
}
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border border-gray-200">
<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">SKU</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Coste (sin IVA)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">PVP (IVA incl.)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Oferta (IVA incl.)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen bruto %</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Neto (sin IVA)</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{variants.map(v => {
const p = prices[v.id];
const netCents = parseInt(net[v.id] ?? '0', 10);
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
const vr = vatRate[v.id] ?? 'general';
const grossCents = calcGross(netCents, vr);
const marginBruto = calcMarginBruto(grossCents, costCents);
const editing = saving === v.id;
return (
<tr key={v.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{v.sku}</td>
{/* Coste */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={cost[v.id] ?? ''}
disabled={editing}
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
placeholder="0.00"
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* PVP (gross) */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={grossCents}
disabled={editing}
onChange={e => {
const gross = parseInt(e.target.value, 10) || 0;
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
}}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
/>
</div>
</td>
{/* Oferta */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={offer[v.id] ?? ''}
disabled={editing}
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
placeholder="—"
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* IVA */}
<td className="px-4 py-3">
<select
value={vatRate[v.id] ?? 'general'}
disabled={editing}
onChange={e => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' }))}
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
>
<option value="general">21% gen.</option>
<option value="reduced">10% red.</option>
</select>
</td>
{/* Margen bruto */}
<td className="px-4 py-3">
{costCents > 0 ? (
<span className={`text-xs font-bold ${marginBruto > 30 ? 'text-green-600' : marginBruto > 10 ? 'text-amber-600' : 'text-red-600'}`}>
{marginBruto}%
</span>
) : (
<span className="text-xs text-gray-300"></span>
)}
</td>
{/* Neto */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={netCents}
disabled={editing}
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* Guardar */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => savePrice(v.id)}
disabled={editing}
className="px-3 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50 transition-colors"
>
{editing ? '...' : 'Guardar'}
</button>
{msg[v.id] && (
<span className={`text-xs ${msg[v.id] === '✓' ? 'text-green-600' : 'text-red-600'}`}>
{msg[v.id]}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
<p><strong>Margen bruto:</strong> (PVP Coste) ÷ PVP × 100. Verde &gt;30%, ámbar 10-30%, rojo &lt;10%.</p>
<p><strong>IVA:</strong> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,314 @@
import { ApiError } from '@/types';
/**
* All requests go to /api/* (relative paths) — the Next.js catch-all
* route handler proxies them to the backend. This keeps all traffic
* within the same origin, avoiding CORS preflights entirely.
*/
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
credentials: 'include',
});
if (res.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
}
if (res.status === 403) {
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ message: 'Request failed' }));
throw new ApiError(
res.status,
(body as { code?: string }).code ?? 'REQUEST_FAILED',
(body as { message?: string }).message ?? 'Request failed',
);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
// ── Auth ──────────────────────────────────────────────────────────────────────
export const authApi = {
login: (email: string, password: string) =>
api.post<{ id: string; email: string; role: string }>('/api/auth/login', { email, password }),
logout: () => api.post('/api/auth/logout'),
me: () =>
api.get<{ id: string; email: string; role: string } | { user: null }>('/api/auth/me'),
};
// ── Products ──────────────────────────────────────────────────────────────────
export const productsApi = {
list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Product[]; total: number }>(
`/api/catalog/products${qs ? `?${qs}` : ''}`,
);
},
get: (id: string) => api.get<import('@/types').Product>(`/api/catalog/products/${id}`),
getVariants: (id: string) =>
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/catalog/products/${id}/variants`),
create: (data: unknown) => api.post<import('@/types').Product>('/api/catalog/products', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Product>(`/api/catalog/products/${id}`, data),
setState: (id: string, state: 'active' | 'archived') =>
api.patch(`/api/catalog/products/${id}/state`, { state }),
delete: (id: string) => api.delete(`/api/catalog/products/${id}`),
};
// ── Orders ────────────────────────────────────────────────────────────────────
export const ordersApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Order[]; total: number }>(
`/api/orders${qs ? `?${qs}` : ''}`,
);
},
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
transition: (id: string, state: string) =>
api.post<import('@/types').Order>(`/api/orders/${id}/transitions`, { state }),
};
// ── Customers ─────────────────────────────────────────────────────────────────
export const customersApi = {
list: (params?: { offset?: number; limit?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
if (params?.limit !== undefined) sp.set('limit', String(params.limit));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Customer[]; total: number }>(`/api/users${qs ? `?${qs}` : ''}`);
},
get: (id: string) => api.get<import('@/types').Customer>(`/api/users/${id}`),
update: (id: string, data: { displayName?: string; phone?: string }) =>
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
api.post<import('@/types').Customer>('/api/auth/register', data),
};
// ── Brands ────────────────────────────────────────────────────────────────────
export const brandsApi = {
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'),
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/brands/${id}`),
};
// ── Categories ────────────────────────────────────────────────────────────────
export const categoriesApi = {
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'),
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
};
// ── Inventory ─────────────────────────────────────────────────────────────────
export const inventoryApi = {
getAvailability: (variantId: string) =>
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
setStock: (id: string, quantity: number) =>
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
};
// ── Pricing ───────────────────────────────────────────────────────────────────
export const pricingApi = {
getVariantPrice: (id: string) => api.get<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`),
setVariantPrice: (
id: string,
netUnitAmountCents: number,
vatRate: 'general' | 'reduced',
offerCents?: number | null,
costCents?: number | null,
) =>
api.put<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`, {
netUnitAmountCents,
vatRate,
offerCents: offerCents ?? null,
costCents: costCents ?? null,
}),
};
// ── Promotions ────────────────────────────────────────────────────────────────
export const promotionsApi = {
list: () => api.get<{ items: unknown[] }>('/api/promotions'),
create: (data: unknown) => api.post('/api/promotions', data),
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
delete: (code: string) => api.delete(`/api/promotions/${code}`),
};
// ── Reviews ───────────────────────────────────────────────────────────────────
export const reviewsApi = {
listAdmin: (params?: { status?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.status) sp.set('status', params.status);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: unknown[]; total: number }>(`/api/reviews/admin${qs ? `?${qs}` : ''}`);
},
moderate: (id: string, status: 'published' | 'rejected') =>
api.patch(`/api/reviews/${id}/moderate`, { status }),
};
// ── CMS ───────────────────────────────────────────────────────────────────────
export const cmsApi = {
list: () => api.get<{ items: unknown[] }>('/api/cms/pages'),
get: (slug: string) => api.get(`/api/cms/pages/${slug}`),
create: (data: unknown) => api.post('/api/cms/pages', data),
update: (id: string, data: unknown) => api.patch(`/api/cms/pages/${id}`, data),
publish: (id: string) => api.post(`/api/cms/pages/${id}/publish`, {}),
unpublish: (id: string) => api.post(`/api/cms/pages/${id}/unpublish`, {}),
};
// ── Admin Users ─────────────────────────────────────────────────────────────────
export const adminUsersApi = {
list: (params?: { limit?: number; offset?: number; role?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.role) sp.set('role', params.role);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: { id: string; email: string; role: string; createdAt: string }[]; total: number }>(
`/api/admin/users${qs ? `?${qs}` : ''}`,
);
},
create: (data: { email: string; password: string; role: string }) =>
api.post<{ id: string; email: string; role: string; createdAt: string }>('/api/admin/users', data),
update: (id: string, data: { role?: string; password?: string }) =>
api.patch<{ id: string; email: string; role: string; createdAt: string }>(`/api/admin/users/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/admin/users/${id}`),
};
// ── Tax Rates ────────────────────────────────────────────────────────────────────
export interface TaxRate {
id: string; name: string; ratePercent: number; country: string; appliesTo: string; active: boolean;
}
export const taxApi = {
list: () => api.get<{ items: TaxRate[] }>('/api/admin/tax-rates'),
update: (id: string, data: Partial<{ name: string; ratePercent: number; active: boolean }>) =>
api.patch('/api/admin/tax-rates/' + id, data),
};
// ── Payments ──────────────────────────────────────────────────────────────────────
export interface PaymentTransaction {
id: string; provider: string; providerPaymentId: string | null;
orderId: string | null; amountCents: number; currency: string;
status: string; raw: unknown; createdAt: string;
}
export const paymentsApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: PaymentTransaction[]; total: number }>(`/api/admin/payments${qs ? `?${qs}` : ''}`);
},
refund: (id: string) => api.post<{ ok: boolean }>(`/api/admin/payments/${id}/refund`, {}),
};
// ── Shipping ───────────────────────────────────────────────────────────────────────
export interface ShippingZone {
id: string; name: string; country: string; postalCodePrefix: string | null; active: boolean;
}
export interface ShippingMethod {
id: string; zoneId: string; zoneName: string; name: string;
baseCostCents: number; freeShippingThresholdCents: number | null; active: boolean;
}
export const shippingApi = {
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
createZone: (data: { name: string; country: string; postalCodePrefix?: string | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/zones', data),
updateZone: (id: string, data: Partial<{ name: string; country: string; postalCodePrefix?: string | null; active: boolean }>) =>
api.patch('/api/admin/shipping/zones/' + id, data),
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/methods', data),
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active: boolean }>) =>
api.patch('/api/admin/shipping/methods/' + id, data),
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
};
// ── Store Settings ─────────────────────────────────────────────────────────────────
export const auditApi = {
list: (params?: { actorId?: string; action?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.action) sp.set('action', params.action);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: AuditEntry[]; total: number }>(`/api/admin/audit${qs ? `?${qs}` : ''}`);
},
};
export interface AuditEntry {
id: string;
actorId: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
createdAt: string;
}
export interface StoreSettings {
storeName: string;
storeTagline: string;
contactEmail: string;
contactPhone: string;
contactAddress: string;
footerText: string;
facebookUrl: string;
instagramUrl: string;
}
export const settingsApi = {
get: () => api.get<StoreSettings>('/api/admin/settings'),
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
};

View File

@@ -0,0 +1,63 @@
import type { Role } from '@/types';
export type Permission =
| 'dashboard'
| 'products.read'
| 'products.write'
| 'orders.read'
| 'orders.write'
| 'inventory.read'
| 'inventory.write'
| 'customers.read'
| 'customers.write'
| 'categories.read'
| 'categories.write'
| 'categories.delete'
| 'brands.read'
| 'brands.write'
| 'promotions.read'
| 'promotions.write'
| 'reviews.read'
| 'reviews.moderate'
| 'cms.read'
| 'cms.write'
| 'admin-users.read'
| 'admin-users.write'
| 'audit.read';
export function can(role: Role, permission: Permission): boolean {
if (role === 'admin') return true;
// Future: granular permission checks when backend supports them
return false;
}
export interface NavItem {
href: string;
label: string;
icon: string;
permission: Permission;
badge?: number;
}
export const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },
{ href: '/categories', label: 'Categorías', icon: '🏷️', permission: 'categories.read' },
{ href: '/brands', label: 'Marcas', icon: '🏷️', permission: 'brands.read' },
{ href: '/promotions', label: 'Promociones', icon: '🏷️', permission: 'promotions.read' },
{ href: '/shipping', label: 'Envíos', icon: '📦', permission: 'orders.read' },
{ href: '/reviews', label: 'Reseñas', icon: '⭐', permission: 'reviews.read' },
{ href: '/cms', label: 'CMS', icon: '📄', permission: 'cms.read' },
{ href: '/users', label: 'Usuarios', icon: '🔐', permission: 'admin-users.read' },
{ href: '/tax-rates', label: 'IVA', icon: '📊', permission: 'orders.read' },
{ href: '/audit', label: 'Auditoría', icon: '📋', permission: 'audit.read' },
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
];
export function visibleNavItems(role: Role): NavItem[] {
return NAV_ITEMS.filter((item) => can(role, item.permission));
}

View File

@@ -0,0 +1,178 @@
// ── User / Auth ──────────────────────────────────────────────────────────────
export type Role = 'customer' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: Role;
}
// ── Products ─────────────────────────────────────────────────────────────────
export interface ProductImage {
id: string;
url: string;
altText?: string;
position?: number;
role?: 'main' | 'gallery';
}
export interface Product {
id: string;
name: string;
slug: string;
description?: string;
state: string;
channels: 'online' | 'offline' | 'all';
featured: boolean;
attributes: string[];
seoTitle?: string;
seoDescription?: string;
images: ProductImage[];
brandId?: string;
categoryIds?: string[];
brand?: { id: string; name: string; slug: string };
imageUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface ProductVariant {
id: string;
productId: string;
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
}
export interface VariantPrice {
variantId: string;
netUnitAmountCents: number;
offerCents: number | null;
costCents: number | null;
vatRate: 'general' | 'reduced';
currency: string;
}
export interface StockAvailability {
available: boolean;
availableQuantity: number;
}
export interface StockItem {
id: string;
variantId: string;
available: number;
reserved: number;
sold: number;
incoming: number;
createdAt: string;
updatedAt: string;
}
// ── Orders ────────────────────────────────────────────────────────────────────
export type OrderState =
| 'PENDING'
| 'AWAITING_PAYMENT'
| 'PAID'
| 'PROCESSING'
| 'SHIPPED'
| 'DELIVERED'
| 'CANCELLED'
| 'REFUNDED'
| 'PARTIALLY_REFUNDED';
export interface OrderItem {
id: string;
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
createdAt: string;
}
export interface Order {
id: string;
userId: string;
state: OrderState;
currency: 'EUR';
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
idempotencyKey: string | null;
items: OrderItem[];
createdAt: string;
updatedAt: string;
}
export interface OrderSummary {
id: string;
userId: string;
state: OrderState;
totalCents: number;
currency: 'EUR';
itemCount: number;
createdAt: string;
}
// ── Customers ─────────────────────────────────────────────────────────────────
export interface Customer {
id: string;
email: string;
role: Role;
displayName?: string;
phone?: string;
createdAt: string;
updatedAt?: string;
}
// ── Categories & Brands ────────────────────────────────────────────────────────
export interface Category {
id: string;
parentId: string | null;
name: string;
slug: string;
seoTitle?: string;
seoDescription?: string;
imageUrl?: string;
description?: string;
children?: Category[];
}
export interface Brand {
id: string;
name: string;
slug: string;
logoUrl?: string;
seoTitle?: string;
seoDescription?: string;
}
// ── API Errors ────────────────────────────────────────────────────────────────
export interface ApiErrorBody {
statusCode: number;
code: string;
message: string;
}
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}

View File

@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long