516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
import React, { forwardRef, useState, useEffect, useCallback } from 'react';
|
|
import { clsx } from 'clsx';
|
|
import { Link } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, ThemeToggle } from '../../ui';
|
|
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
|
import { getRegisterUrl, getLoginUrl } from '../../../utils/navigation';
|
|
import { X } from 'lucide-react';
|
|
|
|
export interface PublicHeaderProps {
|
|
className?: string;
|
|
/**
|
|
* Custom logo component
|
|
*/
|
|
logo?: React.ReactNode;
|
|
/**
|
|
* Show theme toggle
|
|
*/
|
|
showThemeToggle?: boolean;
|
|
/**
|
|
* Show authentication buttons (login/register)
|
|
*/
|
|
showAuthButtons?: boolean;
|
|
/**
|
|
* Show language selector
|
|
*/
|
|
showLanguageSelector?: boolean;
|
|
/**
|
|
* Custom navigation items
|
|
*/
|
|
navigationItems?: Array<{
|
|
id: string;
|
|
label: string;
|
|
href: string;
|
|
external?: boolean;
|
|
}>;
|
|
/**
|
|
* Header variant
|
|
*/
|
|
variant?: 'default' | 'transparent' | 'minimal';
|
|
}
|
|
|
|
export interface PublicHeaderRef {
|
|
scrollIntoView: () => void;
|
|
}
|
|
|
|
/**
|
|
* PublicHeader - Header component for public pages (landing, login, register)
|
|
*
|
|
* Features:
|
|
* - Clean, minimal design suitable for public pages
|
|
* - Integrated theme toggle for consistent theming
|
|
* - Authentication buttons (login/register)
|
|
* - Optional custom navigation items
|
|
* - Multiple visual variants
|
|
* - Responsive design with mobile optimization
|
|
* - Accessible navigation structure
|
|
*/
|
|
export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
|
className,
|
|
logo,
|
|
showThemeToggle = true,
|
|
showAuthButtons = true,
|
|
showLanguageSelector = true,
|
|
navigationItems = [],
|
|
variant = 'default',
|
|
}, ref) => {
|
|
const { t } = useTranslation();
|
|
const headerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// State for mobile menu
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
|
|
// State for sticky header
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
|
|
// State for active section
|
|
const [activeSection, setActiveSection] = useState<string>('');
|
|
|
|
// Default navigation items
|
|
const defaultNavItems: Array<{id: string; label: string; href: string; external?: boolean}> = [
|
|
{ id: 'home', label: t('common:nav.home', 'Inicio'), href: '/' },
|
|
{ id: 'features', label: t('common:nav.features', 'Funcionalidades'), href: '/features' },
|
|
{ id: 'about', label: t('common:nav.about', 'Nosotros'), href: '/about' },
|
|
{ id: 'contact', label: t('common:nav.contact', 'Contacto'), href: '/help/support' }
|
|
];
|
|
|
|
const navItems = navigationItems.length > 0 ? navigationItems : defaultNavItems;
|
|
|
|
// Smooth scroll to section
|
|
const scrollToSection = useCallback((href: string) => {
|
|
if (href.startsWith('#')) {
|
|
const element = document.querySelector(href);
|
|
if (element) {
|
|
const headerHeight = headerRef.current?.offsetHeight || 0;
|
|
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
|
const offsetPosition = elementPosition - headerHeight - 20; // 20px additional offset
|
|
|
|
window.scrollTo({
|
|
top: offsetPosition,
|
|
behavior: 'smooth'
|
|
});
|
|
|
|
// Update URL hash
|
|
window.history.pushState(null, '', href);
|
|
|
|
// Close mobile menu
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Handle scroll for sticky header
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setIsScrolled(window.scrollY > 20);
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
// Active section detection with Intersection Observer
|
|
useEffect(() => {
|
|
const observerOptions = {
|
|
rootMargin: '-100px 0px -66%',
|
|
threshold: 0
|
|
};
|
|
|
|
const observerCallback = (entries: IntersectionObserverEntry[]) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
const id = entry.target.getAttribute('id');
|
|
if (id) {
|
|
setActiveSection(id);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const observer = new IntersectionObserver(observerCallback, observerOptions);
|
|
|
|
// Observe all sections that are navigation targets
|
|
navItems.forEach(item => {
|
|
if (item.href.startsWith('#')) {
|
|
const element = document.querySelector(item.href);
|
|
if (element) {
|
|
observer.observe(element);
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}, [navItems]);
|
|
|
|
// Close mobile menu on ESC key
|
|
useEffect(() => {
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isMobileMenuOpen) {
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleEscape);
|
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
}, [isMobileMenuOpen]);
|
|
|
|
// Prevent body scroll when mobile menu is open
|
|
useEffect(() => {
|
|
if (isMobileMenuOpen) {
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, [isMobileMenuOpen]);
|
|
|
|
// Scroll into view
|
|
const scrollIntoView = useCallback(() => {
|
|
headerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, []);
|
|
|
|
// Expose ref methods
|
|
React.useImperativeHandle(ref, () => ({
|
|
scrollIntoView,
|
|
}), [scrollIntoView]);
|
|
|
|
// Render navigation link with improved styles and active state
|
|
const renderNavLink = (item: typeof navItems[0], isMobile = false) => {
|
|
const isActive = activeSection === item.id || (item.href.startsWith('#') && item.href === `#${activeSection}`);
|
|
|
|
const linkContent = (
|
|
<span className={clsx(
|
|
"relative text-sm font-medium transition-all duration-200",
|
|
isMobile ? "text-base py-1" : "",
|
|
isActive
|
|
? "text-[var(--color-primary)]"
|
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
// Animated underline on hover
|
|
!isMobile && "after:content-[''] after:absolute after:bottom-[-4px] after:left-0 after:w-0 after:h-[2px] after:bg-[var(--color-primary)] after:transition-all after:duration-300 hover:after:w-full",
|
|
// Active state indicator
|
|
isActive && !isMobile && "after:w-full"
|
|
)}>
|
|
{item.label}
|
|
</span>
|
|
);
|
|
|
|
if (item.href.startsWith('#')) {
|
|
return (
|
|
<a
|
|
key={item.id}
|
|
href={item.href}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
scrollToSection(item.href);
|
|
}}
|
|
className={clsx(
|
|
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
|
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
)}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
>
|
|
{linkContent}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
if (item.external || item.href.startsWith('http')) {
|
|
return (
|
|
<a
|
|
key={item.id}
|
|
href={item.href}
|
|
target={item.external ? '_blank' : undefined}
|
|
rel={item.external ? 'noopener noreferrer' : undefined}
|
|
className={clsx(
|
|
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
|
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
)}
|
|
>
|
|
{linkContent}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={item.id}
|
|
to={item.href}
|
|
className={clsx(
|
|
"focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 rounded-sm",
|
|
isMobile && "block w-full py-3 px-4 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
)}
|
|
>
|
|
{linkContent}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Skip to main content link for accessibility */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-4 focus:left-4 focus:px-4 focus:py-2 focus:bg-[var(--color-primary)] focus:text-white focus:rounded-md"
|
|
>
|
|
{t('common:header.skip_to_content', 'Saltar al contenido principal')}
|
|
</a>
|
|
|
|
<header
|
|
ref={headerRef}
|
|
className={clsx(
|
|
'w-full transition-all duration-300',
|
|
// Sticky header
|
|
'fixed top-0 left-0 right-0 z-40',
|
|
// Base styles with scroll effect
|
|
variant === 'default' && [
|
|
isScrolled
|
|
? 'bg-[var(--bg-primary)]/95 backdrop-blur-md border-b border-[var(--border-primary)] shadow-lg'
|
|
: 'bg-[var(--bg-primary)] border-b border-[var(--border-primary)] shadow-sm'
|
|
],
|
|
variant === 'transparent' && [
|
|
isScrolled
|
|
? 'bg-[var(--bg-primary)]/95 backdrop-blur-md shadow-lg'
|
|
: 'bg-transparent'
|
|
],
|
|
variant === 'minimal' && 'bg-[var(--bg-primary)]',
|
|
className
|
|
)}
|
|
role="banner"
|
|
aria-label="Navegación principal"
|
|
>
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className={clsx(
|
|
"flex justify-between items-center transition-all duration-300",
|
|
isScrolled ? "py-3 lg:py-4" : "py-4 lg:py-6"
|
|
)}>
|
|
{/* Logo and brand */}
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
|
{logo || (
|
|
<>
|
|
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
|
PI
|
|
</div>
|
|
<h1 className="text-xl lg:text-2xl font-bold text-[var(--text-primary)]">
|
|
Panadería IA
|
|
</h1>
|
|
</>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Desktop navigation */}
|
|
<nav className="hidden md:flex items-center space-x-8" role="navigation">
|
|
{navItems.map((item) => renderNavLink(item, false))}
|
|
</nav>
|
|
|
|
{/* Right side actions */}
|
|
<div className="flex items-center gap-2 lg:gap-3">
|
|
{/* Language selector - More compact */}
|
|
{showLanguageSelector && (
|
|
<div className="hidden sm:flex">
|
|
<CompactLanguageSelector className="w-[70px]" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Theme toggle */}
|
|
{showThemeToggle && (
|
|
<ThemeToggle
|
|
variant="button"
|
|
size="md"
|
|
className="hidden sm:flex"
|
|
/>
|
|
)}
|
|
|
|
{/* Authentication buttons - Enhanced */}
|
|
{showAuthButtons && (
|
|
<div className="flex items-center gap-2 lg:gap-3">
|
|
<Link to={getLoginUrl()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="md"
|
|
className="hidden sm:inline-flex font-medium hover:bg-[var(--bg-secondary)] transition-all duration-200"
|
|
>
|
|
{t('common:header.login')}
|
|
</Button>
|
|
</Link>
|
|
<Link to={getRegisterUrl()}>
|
|
<Button
|
|
size="md"
|
|
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-200 px-6"
|
|
>
|
|
<span className="hidden sm:inline">{t('common:header.start_free')}</span>
|
|
<span className="sm:hidden">{t('common:header.register')}</span>
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile theme toggle */}
|
|
{showThemeToggle && (
|
|
<ThemeToggle
|
|
variant="button"
|
|
size="sm"
|
|
className="sm:hidden"
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile menu button */}
|
|
<div className="md:hidden">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="p-2 min-h-[44px] min-w-[44px]"
|
|
aria-label={isMobileMenuOpen ? t('common:header.close_menu', 'Cerrar menú') : t('common:header.open_menu', 'Abrir menú')}
|
|
aria-expanded={isMobileMenuOpen}
|
|
aria-controls="mobile-menu"
|
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
>
|
|
<svg
|
|
className={clsx("w-6 h-6 transition-transform duration-300", isMobileMenuOpen && "rotate-90")}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
{isMobileMenuOpen ? (
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
) : (
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
)}
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Spacer to prevent content from hiding under fixed header */}
|
|
<div className={clsx(
|
|
"transition-all duration-300",
|
|
isScrolled ? "h-[72px] lg:h-[80px]" : "h-[80px] lg:h-[96px]"
|
|
)} aria-hidden="true" />
|
|
|
|
{/* Mobile menu drawer */}
|
|
{isMobileMenuOpen && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 md:hidden animate-in fade-in duration-200"
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Drawer */}
|
|
<div
|
|
id="mobile-menu"
|
|
className={clsx(
|
|
"fixed top-0 right-0 bottom-0 w-[85%] max-w-sm bg-[var(--bg-primary)] shadow-2xl z-50 md:hidden",
|
|
"animate-in slide-in-from-right duration-300",
|
|
"flex flex-col"
|
|
)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('common:header.mobile_menu', 'Menú de navegación móvil')}
|
|
>
|
|
{/* Drawer header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
|
PI
|
|
</div>
|
|
<h2 className="text-lg font-bold text-[var(--text-primary)]">
|
|
Panadería IA
|
|
</h2>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="p-2 min-h-[44px] min-w-[44px]"
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
aria-label={t('common:header.close_menu', 'Cerrar menú')}
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Drawer content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Navigation items */}
|
|
<nav className="py-4" role="navigation">
|
|
<div className="flex flex-col">
|
|
{navItems.map((item) => (
|
|
<div key={item.id}>
|
|
{renderNavLink(item, true)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Mobile language selector */}
|
|
{showLanguageSelector && (
|
|
<div className="px-4 py-4 border-t border-[var(--border-primary)]">
|
|
<div className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
|
{t('common:header.language', 'Idioma')}
|
|
</div>
|
|
<CompactLanguageSelector className="w-full" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Theme toggle for mobile */}
|
|
{showThemeToggle && (
|
|
<div className="px-4 py-4 border-t border-[var(--border-primary)]">
|
|
<div className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
|
{t('common:header.theme', 'Tema')}
|
|
</div>
|
|
<ThemeToggle variant="button" size="md" className="w-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Drawer footer with auth buttons */}
|
|
{showAuthButtons && (
|
|
<div className="p-4 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
|
<div className="flex flex-col gap-3">
|
|
<Link to={getLoginUrl()} onClick={() => setIsMobileMenuOpen(false)}>
|
|
<Button
|
|
variant="outline"
|
|
size="lg"
|
|
className="w-full font-medium min-h-[48px]"
|
|
>
|
|
{t('common:header.login', 'Iniciar Sesión')}
|
|
</Button>
|
|
</Link>
|
|
<Link to={getRegisterUrl()} onClick={() => setIsMobileMenuOpen(false)}>
|
|
<Button
|
|
size="lg"
|
|
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg min-h-[48px]"
|
|
>
|
|
{t('common:header.start_free', 'Comenzar Gratis')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
PublicHeader.displayName = 'PublicHeader'; |