feat(ADM-018): completed feature
This commit is contained in:
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