feat(F-048): completed feature
This commit is contained in:
@@ -78,7 +78,7 @@ export default function LoginPage() {
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
MercadoDeVida
|
||||
mercadodevida
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
|
||||
</div>
|
||||
@@ -170,7 +170,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
© {new Date().getFullYear()} MercadoDeVida
|
||||
© {new Date().getFullYear()} mercadodevida
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Brand } from '@/types';
|
||||
import { brandsApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
@@ -17,7 +18,7 @@ function autoSeoTitle(name: string): string {
|
||||
}
|
||||
|
||||
function autoSeoDescription(name: string): string {
|
||||
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
return `${name} — Compra online en mercadodevida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
}
|
||||
|
||||
export default function BrandsPage() {
|
||||
@@ -228,10 +229,7 @@ export default function BrandsPage() {
|
||||
<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>
|
||||
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { Category } from '@/types';
|
||||
import { categoriesApi } from '@/lib/api-client';
|
||||
|
||||
@@ -24,7 +24,16 @@ function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Cat
|
||||
</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 text-sm">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cat.isParent ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{cat.isParent ? '📂 Parent' : '🍃 Child'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{cat.parentId ? 'Sí' : <span className="italic text-gray-400">Raíz</span>}
|
||||
</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">
|
||||
@@ -43,6 +52,32 @@ function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Cat
|
||||
);
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
slugManual: boolean;
|
||||
description: string;
|
||||
parentId: string | null;
|
||||
isParent: boolean;
|
||||
seoTitle: string;
|
||||
seoTitleManual: boolean;
|
||||
seoDescription: string;
|
||||
seoDescManual: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
slug: '',
|
||||
slugManual: false,
|
||||
description: '',
|
||||
parentId: null,
|
||||
isParent: false,
|
||||
seoTitle: '',
|
||||
seoTitleManual: false,
|
||||
seoDescription: '',
|
||||
seoDescManual: false,
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,15 +85,7 @@ export default function CategoriesPage() {
|
||||
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 [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
@@ -76,39 +103,65 @@ export default function CategoriesPage() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Listado plano de categorías que son PARENT (contenedores) y pueden tener hijos.
|
||||
// Excluye la propia categoría que se está editando para evitar auto-anidado.
|
||||
const parentOptions = useMemo(() => {
|
||||
const flat = (cats: Category[]): Category[] =>
|
||||
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
|
||||
return flat(tree)
|
||||
.filter((c) => c.isParent && (!editing || c.id !== editing.id));
|
||||
}, [tree, editing]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setDescription('');
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescManual(false);
|
||||
setForm(EMPTY_FORM);
|
||||
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);
|
||||
setForm({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
slugManual: true,
|
||||
description: cat.description ?? '',
|
||||
parentId: cat.parentId,
|
||||
isParent: cat.isParent ?? false,
|
||||
seoTitle: (cat as any).seoTitle ?? '',
|
||||
seoTitleManual: true,
|
||||
seoDescription: (cat as any).seoDescription ?? '',
|
||||
seoDescManual: 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.`);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: value,
|
||||
slug: f.slugManual ? f.slug : slugify(value),
|
||||
seoTitle: f.seoTitleManual ? f.seoTitle : value,
|
||||
seoDescription: f.seoDescManual ? f.seoDescription : `${value} — Compra online en mercadodevida. Productos naturales y ecológicos.`,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug,
|
||||
description: form.description || undefined,
|
||||
parentId: form.parentId,
|
||||
isParent: form.isParent,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
};
|
||||
if (editing) {
|
||||
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
await categoriesApi.update(editing.id, payload);
|
||||
setMsg('Categoría actualizada');
|
||||
} else {
|
||||
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
await categoriesApi.create(payload);
|
||||
setMsg('Categoría creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
@@ -154,7 +207,7 @@ export default function CategoriesPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
value={form.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"
|
||||
@@ -165,22 +218,53 @@ export default function CategoriesPage() {
|
||||
<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>
|
||||
<span className="text-xs text-gray-400">{form.slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slugManual: true, slug: 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>
|
||||
|
||||
{/* FIX-19: tipo parent/child + categoría padre */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Categoría padre</label>
|
||||
<select
|
||||
value={form.parentId ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, parentId: e.target.value || null }))}
|
||||
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 padre (raíz) —</option>
|
||||
{parentOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Solo se listan las categorías marcadas como parent (contenedor).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer px-4 py-2.5 border border-gray-200 rounded-xl bg-gray-50 w-full">
|
||||
<input type="checkbox" checked={form.isParent}
|
||||
onChange={(e) => setForm((f) => ({ ...f, isParent: e.target.checked }))}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="font-medium">📂 Es categoría parent (contenedor)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Una categoría <strong>child</strong> es una hoja: no puede contener hijos. Si necesita tener subcategorías, márquela como parent.
|
||||
</p>
|
||||
|
||||
{/* 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)}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: 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"
|
||||
@@ -191,44 +275,44 @@ export default function CategoriesPage() {
|
||||
<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 className={`text-xs ${form.seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{form.seoTitleManual ? 'editado' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={seoTitle}
|
||||
value={form.seoTitle}
|
||||
maxLength={60}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seoTitleManual: true, seoTitle: 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 className="mt-1 text-xs text-gray-400">{form.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 className={`text-xs ${form.seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{form.seoDescManual ? 'editada' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
value={form.seoDescription}
|
||||
maxLength={160}
|
||||
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seoDescManual: true, seoDescription: 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 className="mt-1 text-xs text-gray-400">{form.seoDescription.length}/160</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
disabled={saving || !form.name || !form.slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
@@ -255,6 +339,7 @@ export default function CategoriesPage() {
|
||||
<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">Tipo</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>
|
||||
@@ -269,4 +354,4 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,35 @@ import { cmsApi } from '@/lib/api-client';
|
||||
|
||||
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
|
||||
|
||||
// ── Iconos (patrón /categories) ───────────────────────────────────────────────
|
||||
const PencilIcon = (
|
||||
<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>
|
||||
);
|
||||
const EyeIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
const EyeOffIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EMPTY_FORM = { slug: '', title: '', body: '' };
|
||||
|
||||
export default function CmsPage() {
|
||||
const [items, setItems] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Page | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState({ slug: '', title: '', body: '' });
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -21,15 +43,25 @@ export default function CmsPage() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(EMPTY_FORM); setShowForm(true); };
|
||||
const openEdit = (p: Page) => { setEditing(p); setForm({ slug: p.slug, title: p.title, body: p.body }); setShowForm(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
if (editing) {
|
||||
await cmsApi.update(editing.id, { slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página actualizada');
|
||||
} else {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
setForm({ slug: '', title: '', body: '' });
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al guardar'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const togglePublish = async (id: string, currentStatus: string) => {
|
||||
@@ -44,15 +76,15 @@ export default function CmsPage() {
|
||||
<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>
|
||||
<button onClick={openCreate} 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]) => (
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar página' : 'Nueva página'}</h2>
|
||||
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label]) => (
|
||||
<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" />
|
||||
@@ -63,8 +95,8 @@ export default function CmsPage() {
|
||||
<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>
|
||||
<button onClick={handleSave} disabled={saving || !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">{saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear')}</button>
|
||||
<button onClick={() => { setShowForm(false); setEditing(null); }} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -84,9 +116,23 @@ export default function CmsPage() {
|
||||
<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>
|
||||
{/* Acciones con iconos (FIX-13) */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(p)} title="Editar" aria-label="Editar"
|
||||
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors">
|
||||
{PencilIcon}
|
||||
</button>
|
||||
<button onClick={() => togglePublish(p.id, p.status)}
|
||||
title={p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
aria-label={p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
p.status === 'published'
|
||||
? 'text-amber-500 hover:text-amber-600 hover:bg-amber-50'
|
||||
: 'text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50'
|
||||
}`}>
|
||||
{p.status === 'published' ? EyeOffIcon : EyeIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,114 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import type { Customer } from '@/types';
|
||||
import type { Customer, Address } from '@/types';
|
||||
|
||||
// ── FIX-18: edición de datos personales (no credenciales) + CRUD de direcciones ──
|
||||
|
||||
interface AddressFormState {
|
||||
label: string;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ADDRESS: AddressFormState = {
|
||||
label: '',
|
||||
recipientName: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function AddressForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial?: Address;
|
||||
onSave: (data: AddressFormState) => void;
|
||||
onCancel: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [f, setF] = useState<AddressFormState>(
|
||||
initial
|
||||
? {
|
||||
label: initial.label ?? '',
|
||||
recipientName: initial.recipientName,
|
||||
street: initial.street,
|
||||
city: initial.city,
|
||||
postalCode: initial.postalCode,
|
||||
country: initial.country,
|
||||
isDefault: initial.isDefault ?? false,
|
||||
}
|
||||
: EMPTY_ADDRESS,
|
||||
);
|
||||
|
||||
const set = (k: keyof AddressFormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setF((prev) => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const valid = f.recipientName && f.street && f.city && f.postalCode && f.country;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Etiqueta</label>
|
||||
<input value={f.label} onChange={set('label')} placeholder="Casa, Oficina…"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Destinatario *</label>
|
||||
<input value={f.recipientName} onChange={set('recipientName')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Dirección *</label>
|
||||
<input value={f.street} onChange={set('street')} placeholder="Calle, número, piso"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ciudad *</label>
|
||||
<input value={f.city} onChange={set('city')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Código postal *</label>
|
||||
<input value={f.postalCode} onChange={set('postalCode')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">País *</label>
|
||||
<input value={f.country} onChange={set('country')} placeholder="España"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="checkbox" checked={f.isDefault} onChange={(e) => setF((p) => ({ ...p, isDefault: e.target.checked }))}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
Predeterminada
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSave(f)} disabled={!valid || saving}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-lg">
|
||||
{saving ? 'Guardando…' : 'Guardar dirección'}
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-lg hover:bg-white">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -10,29 +116,98 @@ export default function CustomerDetailPage() {
|
||||
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));
|
||||
// Personal data (no credenciales)
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
// Addresses
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [showAddressForm, setShowAddressForm] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||
const [savingAddress, setSavingAddress] = useState(false);
|
||||
|
||||
const loadAddresses = useCallback(async () => {
|
||||
try {
|
||||
const d = await customersApi.listAddresses(id);
|
||||
setAddresses(d.items ?? []);
|
||||
} catch {
|
||||
/* silencioso */
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customer) return;
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
useEffect(() => {
|
||||
customersApi
|
||||
.get(id)
|
||||
.then((c) => {
|
||||
setCustomer(c);
|
||||
setDisplayName(c.displayName ?? '');
|
||||
setPhone(c.phone ?? '');
|
||||
})
|
||||
.catch(() => setError('No se encontró el cliente'))
|
||||
.finally(() => setLoading(false));
|
||||
loadAddresses();
|
||||
}, [id, loadAddresses]);
|
||||
|
||||
const flash = (text: string) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
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 }),
|
||||
const updated = await customersApi.update(id, {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
setMsg('Cliente actualizado');
|
||||
setCustomer(updated);
|
||||
flash('Datos personales guardados');
|
||||
} catch {
|
||||
setMsg('Error al guardar');
|
||||
flash('Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAddress = async (data: AddressFormState) => {
|
||||
setSavingAddress(true);
|
||||
try {
|
||||
const payload = {
|
||||
label: data.label || null,
|
||||
recipientName: data.recipientName,
|
||||
street: data.street,
|
||||
city: data.city,
|
||||
postalCode: data.postalCode,
|
||||
country: data.country,
|
||||
isDefault: data.isDefault,
|
||||
};
|
||||
if (editingAddress) {
|
||||
await customersApi.updateAddress(id, editingAddress.id, payload);
|
||||
flash('Dirección actualizada');
|
||||
} else {
|
||||
await customersApi.createAddress(id, payload);
|
||||
flash('Dirección creada');
|
||||
}
|
||||
setShowAddressForm(false);
|
||||
setEditingAddress(null);
|
||||
loadAddresses();
|
||||
} catch {
|
||||
flash('Error al guardar dirección');
|
||||
} finally {
|
||||
setSavingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAddress = async (addressId: string) => {
|
||||
if (!confirm('¿Eliminar esta dirección?')) return;
|
||||
try {
|
||||
await customersApi.deleteAddress(id, addressId);
|
||||
loadAddresses();
|
||||
} catch {
|
||||
flash('Error al eliminar dirección');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,49 +215,108 @@ export default function CustomerDetailPage() {
|
||||
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">
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Datos personales */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl 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">Datos personales</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">El email y las credenciales de acceso no se pueden modificar desde aquí.</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 className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (solo lectura)</label>
|
||||
<input 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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input 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 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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-400">
|
||||
Registrado: {customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
|
||||
</p>
|
||||
<button onClick={saveProfile} disabled={savingProfile}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">
|
||||
{savingProfile ? 'Guardando…' : 'Guardar datos personales'}
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
{/* Direcciones */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Direcciones ({addresses.length})</h2>
|
||||
{!showAddressForm && (
|
||||
<button onClick={() => { setEditingAddress(null); setShowAddressForm(true); }}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-lg">
|
||||
+ Añadir dirección
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 space-y-4">
|
||||
{showAddressForm && (
|
||||
<AddressForm
|
||||
initial={editingAddress ?? undefined}
|
||||
onSave={handleSaveAddress}
|
||||
onCancel={() => { setShowAddressForm(false); setEditingAddress(null); }}
|
||||
saving={savingAddress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{addresses.length === 0 && !showAddressForm ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">Este cliente no tiene direcciones.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{addresses.map((a) => (
|
||||
<div key={a.id} className="border border-gray-200 rounded-xl p-4 flex items-start justify-between gap-4">
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-gray-900">{a.recipientName}</p>
|
||||
{a.label && <span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">{a.label}</span>}
|
||||
{a.isDefault && <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Predeterminada</span>}
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{a.street}</p>
|
||||
<p className="text-gray-500">{a.postalCode} {a.city}, {a.country}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={() => { setEditingAddress(a); setShowAddressForm(true); }} title="Editar" aria-label="Editar"
|
||||
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors">
|
||||
<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={() => handleDeleteAddress(a.id)} title="Eliminar" aria-label="Eliminar"
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Customer } from '@/types';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -84,69 +86,9 @@ function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: ()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 router = useRouter();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -155,7 +97,6 @@ export default function CustomersPage() {
|
||||
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(() => {
|
||||
@@ -181,7 +122,6 @@ export default function CustomersPage() {
|
||||
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">
|
||||
@@ -255,8 +195,7 @@ export default function CustomersPage() {
|
||||
{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>
|
||||
<RowActions onEdit={() => router.push(`/customers/${c.id}`)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -287,12 +226,6 @@ export default function CustomersPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal editar */}
|
||||
{editing && (
|
||||
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
|
||||
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function Sidebar({
|
||||
<div className="px-4 py-5 border-b border-gray-100">
|
||||
<img
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
alt="mercadodevida"
|
||||
className="h-9 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
@@ -103,7 +103,9 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar navItems={navItems} user={user} onLogout={logout} />
|
||||
<main className="flex-1 min-w-0">
|
||||
{children}
|
||||
<div className="w-full max-w-[1280px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>('/admin/stats')
|
||||
.get<Stats>('/api/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => setError('No se pudieron cargar las estadísticas'))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { promotionsApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
|
||||
|
||||
@@ -125,7 +126,7 @@ export default function PromotionsPage() {
|
||||
<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>
|
||||
<td className="px-4 py-3.5"><RowActions onDelete={() => handleDelete(p.code)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -89,13 +89,6 @@ export default function SettingsPage() {
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
<a href="/shipping" className="block px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<span className="mr-2">🚚</span>Envíos
|
||||
</a>
|
||||
<a href="/tax-rates" className="block px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<span className="mr-2">💰</span>IVA
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Form area */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
type Tab = 'zones' | 'methods';
|
||||
|
||||
@@ -17,8 +18,7 @@ function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () =>
|
||||
</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>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -86,8 +86,7 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi
|
||||
</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>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,10 @@ 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.
|
||||
* FIX-14: admin panel auth now goes through the backoffice auth endpoint,
|
||||
* which sets a separate `backoffice_session` cookie (independent of the
|
||||
* storefront `mdv_session`). This keeps the physical separation between
|
||||
* backoffice users and storefront customers.
|
||||
*/
|
||||
function makeLocalhostCompatible(cookie: string): string {
|
||||
return cookie
|
||||
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch(`${API}/auth/login`, {
|
||||
const backendRes = await fetch(`${API}/backoffice/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -43,4 +44,4 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
/** FIX-14: logout hits the backoffice endpoint and clears backoffice_session. */
|
||||
export async function POST() {
|
||||
try {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
await fetch(`${API}/auth/logout`, {
|
||||
const { cookies } = await import('next/headers');
|
||||
const token = (await cookies()).get('backoffice_session')?.value;
|
||||
await fetch(`${API}/backoffice/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookies },
|
||||
headers: token ? { Cookie: `backoffice_session=${token}` } : {},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete('mdv_session');
|
||||
return response;
|
||||
}
|
||||
const res = NextResponse.json({ ok: true }, { status: 200 });
|
||||
res.cookies.set('backoffice_session', '', { path: '/', maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/** FIX-14: admin panel auth now resolves through the backoffice endpoint. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/auth/me`, {
|
||||
const backendRes = await fetch(`${API}/backoffice/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
if (!backendRes.ok) return NextResponse.json({ user: null });
|
||||
@@ -13,4 +14,4 @@ export async function GET(req: NextRequest) {
|
||||
} catch {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,109 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
const EXTENSION_BY_TYPE: Readonly<Record<string, string>> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/avif': 'avif',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticatedBackofficeRequest(request))) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) {
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
const extension = EXTENSION_BY_TYPE[file.type];
|
||||
if (!extension) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
|
||||
{ error: `Tipo no permitido. Usa: ${Object.keys(EXTENSION_BY_TYPE).join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
|
||||
if (file.size <= 0 || file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo debe ocupar entre 1 byte y 10MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Unique filename
|
||||
const ext = file.name.split('.').pop() ?? 'jpg';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!hasExpectedImageSignature(buffer, file.type)) {
|
||||
return NextResponse.json({ error: 'El contenido no coincide con un formato de imagen válido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}.${extension}`;
|
||||
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);
|
||||
await writeFile(filePath, buffer, { flag: 'wx' });
|
||||
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url, filename, size: file.size });
|
||||
return NextResponse.json({
|
||||
url: `/uploads/${filename}`,
|
||||
filename,
|
||||
size: file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function isAuthenticatedBackofficeRequest(request: NextRequest): Promise<boolean> {
|
||||
const cookies = request.headers.get('cookie');
|
||||
if (!cookies) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API}/backoffice/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = (await response.json()) as { id?: unknown; role?: unknown; user?: null };
|
||||
return (
|
||||
typeof body.id === 'string' &&
|
||||
(body.role === 'admin' || body.role === 'editor') &&
|
||||
body.user !== null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedImageSignature(buffer: Buffer, type: string): boolean {
|
||||
if (type === 'image/jpeg') {
|
||||
return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
}
|
||||
if (type === 'image/png') {
|
||||
return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
|
||||
}
|
||||
if (type === 'image/gif') {
|
||||
const signature = buffer.subarray(0, 6).toString('ascii');
|
||||
return signature === 'GIF87a' || signature === 'GIF89a';
|
||||
}
|
||||
if (type === 'image/webp') {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
);
|
||||
}
|
||||
if (type === 'image/avif') {
|
||||
if (buffer.length < 12 || buffer.subarray(4, 8).toString('ascii') !== 'ftyp') return false;
|
||||
const brands = buffer.subarray(8, Math.min(buffer.length, 32)).toString('ascii');
|
||||
return brands.includes('avif') || brands.includes('avis');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MercadoDeVida Admin',
|
||||
template: '%s | MercadoDeVida Admin',
|
||||
default: 'mercadodevida Admin',
|
||||
template: '%s | mercadodevida Admin',
|
||||
},
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
49
project/apps/admin/src/app/uploads/[filename]/route.ts
Normal file
49
project/apps/admin/src/app/uploads/[filename]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves uploaded files dynamically from disk on every request.
|
||||
*
|
||||
* Next.js production mode caches the public/ directory listing at build/start
|
||||
* time, so files uploaded after startup return 404 when served as static
|
||||
* assets. This route handler reads the file fresh from disk each time,
|
||||
* making newly uploaded images immediately available without a restart.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
|
||||
// Prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const filePath = path.join(process.cwd(), 'public', 'uploads', safe);
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user