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,78 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
const navItems = [
{ href: '/admin', label: 'Dashboard', icon: '📊' },
{ href: '/admin/products', label: 'Productos', icon: '📦' },
{ href: '/admin/orders', label: 'Pedidos', icon: '🧾' },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && (!user || user.role !== 'admin')) {
router.push('/auth/login');
}
}, [user, loading, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (!user || user.role !== 'admin') {
return null;
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
<div className="p-4 border-b border-gray-200">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Admin</p>
<p className="text-sm font-semibold text-gray-900 truncate">{user.email}</p>
</div>
<nav className="p-3 space-y-1">
{navItems.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
active
? 'bg-[#70ad47] text-white'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
);
})}
<div className="pt-4 border-t border-gray-200 mt-4">
<Link href="/" className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 transition-colors">
<span></span>
Ver tienda
</Link>
</div>
</nav>
</aside>
{/* Content */}
<main className="flex-1 p-8">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useAuth } from '@/contexts/AuthContext';
import Link from 'next/link';
export default function UserMenu() {
const { user, logout } = useAuth();
if (user) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 hidden sm:block">
{user.email}
</span>
<button
onClick={logout}
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
Cerrar sesión
</button>
</div>
);
}
return (
<Link
href="/auth/login"
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
Iniciar sesión
</Link>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState } from 'react';
import { useCart } from '@/contexts/CartContext';
interface Props {
variantId: string;
productId: string;
productName: string;
priceCents: number;
imageUrl?: string;
available?: boolean;
className?: string;
}
export default function AddToCartButton({
variantId, productId, productName, priceCents, imageUrl, available = true, className = '',
}: Props) {
const { addItem, itemCount } = useCart();
const [added, setAdded] = useState(false);
const handleAdd = () => {
if (!available) return;
addItem({ variantId, productId, productName, quantity: 1, priceCents, imageUrl });
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
if (!available) {
return (
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
Agotado
</button>
);
}
if (added) {
return (
<button disabled className={`px-8 py-3.5 bg-[#52B788] text-white font-semibold rounded-xl cursor-default ${className}`}>
Añadido
</button>
);
}
return (
<button
onClick={handleAdd}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
>
Añadir al carrito
</button>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import Link from 'next/link';
import { useCart } from '@/contexts/CartContext';
export default function CartLink() {
const { itemCount } = useCart();
return (
<Link
href="/cart"
className="relative p-2 text-gray-600 hover:text-[#70ad47] transition-colors"
aria-label={`Carrito (${itemCount} artículos)`}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-[#E76F51] text-white text-xs font-bold rounded-full flex items-center justify-center">
{itemCount > 9 ? '9+' : itemCount}
</span>
)}
</Link>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useCart, type CartItem } from '@/contexts/CartContext';
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function CartItemRow({ item }: { item: CartItem }) {
const { removeItem, changeQuantity } = useCart();
return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
{/* Image */}
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl ? (
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
) : (
<span className="text-3xl">🌿</span>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<Link href={`/products/${item.productId}`} className="font-semibold text-gray-900 hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{item.productName}
</Link>
<p className="text-gray-500 text-sm mt-1">{formatPrice(item.priceCents)}/ud</p>
{/* Quantity controls */}
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center border border-gray-300 rounded-lg">
<button
onClick={() => changeQuantity(item.variantId, item.quantity - 1)}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
>
</button>
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
<button
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
>
+
</button>
</div>
<button
onClick={() => removeItem(item.variantId)}
className="text-gray-400 hover:text-red-500 transition-colors text-sm"
>
Eliminar
</button>
</div>
</div>
{/* Subtotal */}
<div className="text-right flex-shrink-0">
<p className="font-bold text-[#70ad47]">{formatPrice(item.priceCents * item.quantity)}</p>
</div>
</div>
);
}
export default function CartPageContent() {
const { items, subtotalCents, itemCount, clearCart } = useCart();
if (items.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div className="text-6xl mb-4">🛒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
<p className="text-gray-500 mb-8">Añade productos para empezar tu pedido.</p>
<Link href="/products" className="inline-flex items-center gap-2 px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
Ver productos
</Link>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
Carrito de compra
</h1>
<p className="mt-1 text-gray-500">{itemCount} artículo{itemCount !== 1 ? 's' : ''}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Items */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{items.map((item) => (
<CartItemRow key={item.variantId} item={item} />
))}
</div>
<button
onClick={clearCart}
className="mt-4 text-gray-400 hover:text-red-500 text-sm transition-colors"
>
Vaciar carrito
</button>
</div>
{/* Summary */}
<div>
<div className="bg-gray-50 rounded-xl p-6 border border-gray-200 sticky top-24">
<h2 className="font-bold text-gray-900 mb-4">Resumen del pedido</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal ({itemCount} artículos)</span>
<span className="font-medium">{formatPrice(subtotalCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Envío</span>
<span className="text-gray-500">Calculado en checkout</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Impuestos</span>
<span>Incluidos</span>
</div>
</div>
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(subtotalCents)}</span>
</div>
<Link
href="/checkout"
className="mt-4 w-full block text-center px-6 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
>
Proceder al checkout
</Link>
<Link
href="/products"
className="mt-2 w-full block text-center px-6 py-2 text-sm text-gray-500 hover:text-[#70ad47] transition-colors"
>
Seguir comprando
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { useState } from 'react';
import AddToCartButton from './AddToCartButton';
interface Props {
variantId: string;
productId: string;
productName: string;
priceCents: number;
imageUrl?: string;
available: boolean;
}
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available }: Props) {
return (
<div className="mt-6">
<AddToCartButton
variantId={variantId}
productId={productId}
productName={productName}
priceCents={priceCents}
imageUrl={imageUrl}
available={available}
className="w-full sm:w-auto"
/>
</div>
);
}

View File

@@ -0,0 +1,295 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useCart } from '@/contexts/CartContext';
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
export default function CheckoutClient() {
const { user, loading: authLoading } = useAuth();
const { items, subtotalCents, itemCount, clearCart } = useCart();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
// Form state
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: user?.email ?? '',
phone: '',
address: '',
city: '',
postalCode: '',
notes: '',
shippingMethod: 'standard',
});
if (authLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 flex items-center justify-center">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
<div className="text-6xl mb-4">🛒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
<p className="text-gray-500 mb-8">Añade productos antes de hacer el pedido.</p>
<Link href="/products" className="inline-flex px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
Ver productos
</Link>
</div>
);
}
const shippingCost = form.shippingMethod === 'express' ? 899 : 499;
const totalCents = subtotalCents + shippingCost;
const handlePlaceOrder = async () => {
setSubmitting(true);
setError('');
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
shippingAddress: {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
line1: form.address,
city: form.city,
postalCode: form.postalCode,
country: 'ES',
},
shippingMethod: form.shippingMethod,
notes: form.notes,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error?.message || 'Error al procesar el pedido');
}
const { orderId } = await res.json();
clearCart();
window.location.href = `/order-confirmation?orderId=${orderId}`;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al procesar el pedido');
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8" style={{ fontFamily: 'var(--font-heading)' }}>
Finalizar pedido
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Form */}
<div>
{/* Login prompt — solo si NO está autenticado */}
{!user && (
<div className="bg-[#70ad47]/5 border border-[#70ad47]/20 rounded-xl p-6 mb-6">
<h2 className="font-bold text-gray-900 mb-1">¿Ya tienes cuenta?</h2>
<p className="text-sm text-gray-600 mb-3">Inicia sesión para una experiencia más rápida.</p>
<div className="flex gap-3">
<Link href="/auth/login" className="px-4 py-2 bg-[#70ad47] hover:bg-[#5a9040] text-white text-sm font-semibold rounded-lg transition-colors">
Iniciar sesión
</Link>
<Link href="/auth/register" className="px-4 py-2 border border-gray-300 hover:border-[#70ad47] text-gray-700 text-sm font-semibold rounded-lg transition-colors">
Crear cuenta
</Link>
</div>
</div>
)}
{/* Shipping form */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Datos de envío</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
type="text" required placeholder="María"
value={form.firstName}
onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Apellidos *</label>
<input
type="text" required placeholder="García López"
value={form.lastName}
onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input
type="email" required placeholder="maria@ejemplo.com"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono *</label>
<input
type="tel" required placeholder="+34 600 000 000"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dirección *</label>
<input
type="text" required placeholder="Calle Gran Vía 42"
value={form.address}
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ciudad *</label>
<input
type="text" required placeholder="Madrid"
value={form.city}
onChange={e => setForm(f => ({ ...f, city: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CP *</label>
<input
type="text" required placeholder="28013"
value={form.postalCode}
onChange={e => setForm(f => ({ ...f, postalCode: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">País</label>
<input type="text" disabled value="España"
className="w-full px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500" />
</div>
</div>
<div className="border-t border-gray-200 pt-4 mt-4">
<h3 className="font-semibold text-gray-900 mb-3">Método de envío</h3>
<div className="space-y-2">
{[
{ id: 'standard', name: 'Estándar', desc: 'Entrega 3-5 días laborables', price: '€4.99' },
{ id: 'express', name: 'Express 24h', desc: 'Entrega al día siguiente', price: '€8.99' },
].map(opt => (
<label key={opt.id}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${form.shippingMethod === opt.id ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'}`}>
<input type="radio" name="shipping" value={opt.id} checked={form.shippingMethod === opt.id}
onChange={e => setForm(f => ({ ...f, shippingMethod: e.target.value }))}
className="text-[#70ad47]" />
<div className="flex-1">
<p className="font-medium text-gray-900">{opt.name}</p>
<p className="text-sm text-gray-500">{opt.desc}</p>
</div>
<span className="font-semibold text-[#70ad47]">{opt.price}</span>
</label>
))}
</div>
</div>
<div className="border-t border-gray-200 pt-4">
<h3 className="font-semibold text-gray-900 mb-3">Notas del pedido</h3>
<textarea rows={3} placeholder="Observaciones, instrucciones de entrega..."
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none resize-none"
/>
</div>
</div>
</div>
</div>
{/* Order summary */}
<div>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 sticky top-24">
<h2 className="font-bold text-gray-900 mb-4">Tu pedido ({itemCount})</h2>
<div className="space-y-3 max-h-80 overflow-y-auto mb-4">
{items.map(item => (
<div key={item.variantId} className="flex gap-3">
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
: <span className="text-2xl">🌿</span>
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 line-clamp-1">{item.productName}</p>
<p className="text-xs text-gray-500">Cantidad: {item.quantity}</p>
</div>
<p className="text-sm font-bold text-gray-900 flex-shrink-0">
{formatPrice(item.priceCents * item.quantity)}
</p>
</div>
))}
</div>
<div className="border-t border-gray-200 pt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatPrice(subtotalCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Envío</span>
<span className="text-gray-500">{form.shippingMethod === 'express' ? '€8.99' : '€4.99'}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>IVA</span>
<span>Incluido</span>
</div>
</div>
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(totalCents)}</span>
</div>
{error && (
<div className="mt-3 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-2.5 rounded-xl">
{error}
</div>
)}
<button
onClick={handlePlaceOrder}
disabled={submitting || !form.firstName || !form.email || !form.address || !form.city || !form.postalCode}
className="mt-4 w-full py-3.5 bg-[#70ad47] hover:bg-[#5a9040] disabled:bg-gray-300 disabled:text-gray-500 text-white font-semibold rounded-xl transition-colors cursor-pointer"
>
{submitting ? 'Procesando...' : 'Finalizar pedido'}
</button>
<Link href="/cart" className="mt-3 w-full block text-center text-sm text-gray-500 hover:text-[#70ad47] transition-colors">
Volver al carrito
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
interface Props {
title: string;
description: string;
children: React.ReactNode;
}
export default function ContentPage({ title, description, children }: Props) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1
className="text-4xl font-bold text-gray-900 mb-4"
style={{ fontFamily: 'var(--font-heading)' }}
>
{title}
</h1>
<p className="text-lg text-gray-600 mb-10 leading-relaxed">{description}</p>
<div className="prose prose-gray max-w-none">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
import type { Brand } from '@/types/api';
import { fetchBrands } from '@/lib/api';
export default async function BrandsSection() {
const brands = await fetchBrands();
if (brands.length === 0) return null;
return (
<section className="py-16 bg-white border-t border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Nuestras marcas</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{brands.map((brand) => (
<Link key={brand.id} href={`/brands/${brand.slug}`}>
<div className="p-4 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-gray-50 text-center">
<div className="w-12 h-12 mx-auto mb-2 bg-[#70ad47]/10 rounded-full flex items-center justify-center">
<span className="text-[#70ad47] font-bold text-sm">{brand.name.slice(0, 2).toUpperCase()}</span>
</div>
<p className="font-medium text-gray-900 text-sm">{brand.name}</p>
</div>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import type { Category } from '@/types/api';
import { fetchCategories } from '@/lib/api';
export default async function CategoriesGrid() {
let categories: Awaited<ReturnType<typeof fetchCategories>> = [];
try {
categories = await fetchCategories();
} catch (e) {
console.error('[CategoriesGrid] fetch error:', e);
}
if (categories.length === 0) return null;
const icons: Record<string, string> = {
alimentacion: '🥜',
suplementos: '💊',
'cosmetica-natural': '🌸',
'limpieza-ecologica': '🌿',
'frutos-secos': '🥜',
aceites: '🫒',
'hierbas-infusiones': '🍵',
vitaminas: '💊',
proteinas: '🏋️',
cremas: '🧴',
jabones: '🧼',
};
const colors = [
'bg-[#70ad47]/10 text-[#70ad47]',
'bg-[#E76F51]/10 text-[#E76F51]',
'bg-[#F4A261]/10 text-[#F4A261]',
'bg-[#52B788]/10 text-[#52B788]',
'bg-[#5a9040]/10 text-[#5a9040]',
];
return (
<section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Explora categorías</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{categories.map((cat, i) => {
const totalProducts = cat.children ? cat.children.length * 5 : 5;
return (
<Link key={cat.id} href={`/categories/${cat.slug}`}>
<div className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-white text-center ${colors[i % colors.length]}`}>
<div className="text-4xl mb-2">{icons[cat.slug] ?? '📦'}</div>
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
{cat.children && cat.children.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{cat.children.slice(0, 3).map((child) => (
<span key={child.id} className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{child.name}</span>
))}
</div>
)}
</div>
</Link>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Product } from '@/types/api';
import { fetchProducts } from '@/lib/api';
function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
export default async function FeaturedProducts() {
let allProducts: Awaited<ReturnType<typeof fetchProducts>> = [];
try {
allProducts = await fetchProducts();
} catch (e) {
console.error('[FeaturedProducts] fetch error:', e);
}
const products = allProducts.slice(0, 4);
if (products.length === 0) {
return (
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500">Cargando productos...</p>
</div>
</section>
);
}
return (
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8">Productos destacados</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
{product.brandId && <p className="text-xs text-[#70ad47] font-medium uppercase tracking-wide mb-1">Marca</p>}
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
<p className="text-gray-500 text-sm mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
))}
</div>
<div className="text-center mt-8">
<Link href="/products" className="text-[#70ad47] font-medium hover:text-[#5a9040] transition-colors">
Ver todos los productos
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
import Link from 'next/link';
export function Hero() {
return (
<section className="relative overflow-hidden bg-gradient-to-br from-[#70ad47] via-[#40916C] to-[#52B788]">
{/* Decorative circles */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white opacity-5" />
<div className="absolute top-1/2 -left-16 w-64 h-64 rounded-full bg-white opacity-5" />
<div className="absolute bottom-0 right-1/4 w-48 h-48 rounded-full bg-white opacity-5" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
<div className="max-w-2xl">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 backdrop-blur-sm text-sm text-green-100 mb-6">
<svg className="w-4 h-4 text-green-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
Calidad certificada · 100% natural
</div>
<h1
className="text-4xl md:text-6xl font-bold text-white leading-tight mb-6"
style={{ fontFamily: 'var(--font-heading)' }}
>
Productos naturales y orgánicos para tu bienestar
</h1>
<p className="text-lg md:text-xl text-green-100 leading-relaxed mb-8 max-w-xl">
Descubre nuestra selección de productos ecológicos, saludables y sostenibles. Envío a toda España.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link
href="/products"
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white text-[#70ad47] font-semibold rounded-lg hover:bg-green-50 transition-colors shadow-lg"
>
Ver productos
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</Link>
<Link
href="/categories"
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white/10 backdrop-blur-sm text-white font-semibold rounded-lg hover:bg-white/20 transition-colors border border-white/20"
>
Explorar categorías
</Link>
</div>
{/* Trust badges */}
<div className="flex flex-wrap items-center gap-6 mt-12 text-sm text-green-100">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
Envío gratis +49
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
Pago seguro
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Entrega 24-48h
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,120 @@
import Link from 'next/link';
export function Footer() {
return (
<footer className="mt-auto" style={{ backgroundColor: 'var(--color-footer-bg)', color: 'var(--color-footer-text)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-2 md:col-span-1">
{/* FIX-04: Natural Almagro / Mercado de Vida stacked */}
<div className="mb-3">
<p
className="text-2xl font-bold leading-none"
style={{ fontFamily: 'var(--font-heading)', letterSpacing: '0.04em', color: 'var(--color-footer-text)' }}
>
Natural
</p>
<p
className="text-xs font-normal leading-tight mt-0.5"
style={{ fontFamily: 'var(--font-sans)', color: 'var(--color-footer-muted)', letterSpacing: '0.06em' }}
>
Mercado de Vida
</p>
</div>
<blockquote
className="pl-3 border-l-2 leading-relaxed text-sm italic"
style={{ borderLeftColor: 'var(--color-primary)', color: 'var(--color-footer-muted)' }}
>
Productos naturales y orgánicos para tu bienestar. Calidad certificada, envío a toda España.
</blockquote>
</div>
{/* Shop */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Tienda
</h3>
<ul className="space-y-2">
{[
['/products', 'Productos'],
['/categories', 'Categorías'],
['/brands', 'Marcas'],
['/search', 'Buscar'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Empresa
</h3>
<ul className="space-y-2">
{[
['/about', 'Quiénes somos'],
['/contact', 'Contacto'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Legal
</h3>
<ul className="space-y-2">
{[
['/shipping', 'Envíos'],
['/privacy', 'Privacidad'],
['/terms', 'Términos'],
['/cookies', 'Cookies'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<p className="text-xs" style={{ color: 'rgba(232,245,224,0.4)' }}>
© 2026 MercadoDeVida. Todos los derechos reservados.
</p>
<div className="flex items-center gap-4">
<a href="https://instagram.com/mercadodevida/" target="_blank" rel="noopener" aria-label="Instagram"
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
</a>
<a href="https://www.facebook.com/mercadodevida" target="_blank" rel="noopener" aria-label="Facebook"
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import CartLink from '@/components/cart/CartLink';
import UserMenu from '@/components/auth/UserMenu';
import { fetchProducts, fetchSearchSuggestions } from '@/lib/api';
interface SearchProduct {
id: string;
name: string;
slug: string;
images?: { url: string }[];
}
function LiveSearchResults({
q,
results,
suggestions,
onClose,
onSuggestionClick,
}: {
q: string;
results: SearchProduct[];
suggestions: string[];
onClose: () => void;
onSuggestionClick: (term: string) => void;
}) {
if (!q.trim()) return null;
const hasResults = results.length > 0;
return (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden">
{results.slice(0, 6).map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
onClick={onClose}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{product.images?.[0] ? (
<Image
src={product.images[0].url}
alt={product.name}
width={40}
height={40}
className="object-cover w-full h-full"
/>
) : (
<span className="text-lg">🌿</span>
)}
</div>
<span className="text-sm font-medium text-gray-800 line-clamp-1">{product.name}</span>
</Link>
))}
{!hasResults && (
<div className="px-4 py-3">
<p className="text-sm text-gray-400 mb-2">Sin resultados para &ldquo;{q}&rdquo;</p>
{suggestions.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1">Quizás buscabas:</p>
<div className="flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
onClick={() => onSuggestionClick(s)}
className="text-xs px-2 py-1 bg-[#70ad47]/10 text-[#70ad47] rounded-full hover:bg-[#70ad47]/20 transition-colors font-medium"
>
{s}
</button>
))}
</div>
</div>
)}
</div>
)}
<Link
href={`/search?q=${encodeURIComponent(q)}`}
onClick={onClose}
className="block px-4 py-2.5 text-xs text-center text-[#70ad47] hover:bg-[#70ad47]/5 font-medium border-t border-gray-100 transition-colors"
>
Ver todos los resultados para &ldquo;{q}&rdquo;
</Link>
</div>
);
}
export function Header() {
const [q, setQ] = useState('');
const [results, setResults] = useState<SearchProduct[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const loadResults = async (term: string) => {
setSearching(true);
try {
const [items, suggs] = await Promise.all([
fetchProducts({ q: term, limit: 6 }),
fetchSearchSuggestions(term),
]);
setResults(Array.isArray(items) ? items as SearchProduct[] : []);
setSuggestions(suggs);
setShowDropdown(true);
} catch {
setResults([]);
setSuggestions([]);
setShowDropdown(false);
} finally {
setSearching(false);
}
};
useEffect(() => {
if (!q.trim()) {
setResults([]);
setSuggestions([]);
setShowDropdown(false);
setSearching(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadResults(q.trim());
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [q]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSuggestionClick = (term: string) => {
setQ(term);
setShowDropdown(false);
router.push(`/search?q=${encodeURIComponent(term)}`);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowDropdown(false);
const term = q.trim();
if (term) router.push(`/search?q=${encodeURIComponent(term)}`);
else router.push('/search');
};
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 gap-4">
{/* Logo */}
<Link href="/" className="flex items-center shrink-0">
<Image
src="/images/logo-main.png"
alt="MercadoDeVida"
width={56}
height={56}
className="h-14 w-auto object-contain"
priority
/>
</Link>
{/* Search bar */}
<div className="flex-1 max-w-xl hidden sm:block relative" ref={dropdownRef}>
<form onSubmit={handleSubmit}>
<div className="relative w-full">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => { if (q.trim() && (results.length > 0 || suggestions.length > 0)) setShowDropdown(true); }}
placeholder="Buscar productos, marcas..."
className="w-full pl-10 pr-10 py-2.5 border border-gray-200 rounded-full text-sm
focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none
placeholder-gray-400 text-gray-900 bg-gray-50 hover:bg-white transition-colors"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-[#70ad47] rounded-full animate-spin" />
</div>
)}
</div>
</form>
{showDropdown && (
<LiveSearchResults
q={q}
results={results}
suggestions={suggestions}
onClose={() => setShowDropdown(false)}
onSuggestionClick={handleSuggestionClick}
/>
)}
</div>
{/* Nav */}
<nav className="hidden lg:flex items-center gap-6">
<Link href="/products" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Productos
</Link>
<Link href="/categories" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Categorías
</Link>
<Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Marcas
</Link>
</nav>
{/* Cart + user */}
<div className="flex items-center gap-3 shrink-0">
<Link href="/search" className="sm:hidden text-gray-500 hover:text-[#70ad47]">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Link>
<CartLink />
<UserMenu />
</div>
</div>
</div>
</header>
);
}