feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -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">
&copy; {new Date().getFullYear()} MercadoDeVida
&copy; {new Date().getFullYear()} mercadodevida
</p>
</div>
</div>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },
};

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