feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

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

View File

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

View File

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

View File

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

View File

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