feat(ADM-018): completed feature
This commit is contained in:
78
project/frontend/src/components/admin/AdminLayout.tsx
Normal file
78
project/frontend/src/components/admin/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
project/frontend/src/components/auth/UserMenu.tsx
Normal file
32
project/frontend/src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
project/frontend/src/components/cart/AddToCartButton.tsx
Normal file
52
project/frontend/src/components/cart/AddToCartButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
project/frontend/src/components/cart/CartLink.tsx
Normal file
24
project/frontend/src/components/cart/CartLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
project/frontend/src/components/cart/CartPageContent.tsx
Normal file
149
project/frontend/src/components/cart/CartPageContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
project/frontend/src/components/cart/ProductAddToCart.tsx
Normal file
28
project/frontend/src/components/cart/ProductAddToCart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
project/frontend/src/components/checkout/CheckoutClient.tsx
Normal file
295
project/frontend/src/components/checkout/CheckoutClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
project/frontend/src/components/content/ContentPage.tsx
Normal file
20
project/frontend/src/components/content/ContentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
project/frontend/src/components/home/BrandsSection.tsx
Normal file
29
project/frontend/src/components/home/BrandsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
project/frontend/src/components/home/CategoriesGrid.tsx
Normal file
65
project/frontend/src/components/home/CategoriesGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
project/frontend/src/components/home/FeaturedProducts.tsx
Normal file
64
project/frontend/src/components/home/FeaturedProducts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
project/frontend/src/components/home/Hero.tsx
Normal file
77
project/frontend/src/components/home/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
project/frontend/src/components/layout/Footer.tsx
Normal file
120
project/frontend/src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
project/frontend/src/components/layout/Header.tsx
Normal file
246
project/frontend/src/components/layout/Header.tsx
Normal 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 “{q}”</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 “{q}” →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user