Files
Spanglish/frontend/src/components/layout/Header.tsx
Michilis 62bf048680 Mobile scanner redesign + backend live search
- Scanner page: fullscreen mobile-first layout, Scan/Search/Recent tabs
- Scan tab: auto-start camera, switch camera, vibration/sound feedback
- Valid/invalid fullscreen states, confirm check-in, auto-return to camera
- Search tab: live backend search (300ms debounce), tap card for detail + check-in
- Recent tab: last 20 check-ins, session counter
- Backend: GET /api/tickets/search (live search), GET /api/tickets/stats/checkin
- Admin layout: hide sidebar on scanner page; fix hooks order (no early return before useEffect)
- Back button to dashboard/events (staff → events, others → admin)
- API: searchLive, getCheckinStats, LiveSearchResult; PostgreSQL LOWER cast for UUID

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:26:44 +00:00

318 lines
10 KiB
TypeScript

'use client';
import Link from 'next/link';
import Image from 'next/image';
import { useState, useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
return (
<Link
href={href}
className="font-medium transition-colors"
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
>
{children}
</Link>
);
}
function MobileNavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void }) {
const pathname = usePathname();
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
return (
<Link
href={href}
className="block px-6 py-3 text-lg font-medium transition-colors hover:bg-gray-50"
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
onClick={onClick}
>
{children}
</Link>
);
}
export default function Header() {
const { t } = useLanguage();
const { user, hasAdminAccess, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
const touchCurrentX = useRef<number>(0);
const isDragging = useRef<boolean>(false);
// Close menu on route change
const pathname = usePathname();
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
// Prevent body scroll when menu is open
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [mobileMenuOpen]);
// Handle swipe to close
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchCurrentX.current = e.touches[0].clientX;
isDragging.current = true;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging.current) return;
touchCurrentX.current = e.touches[0].clientX;
const deltaX = touchCurrentX.current - touchStartX.current;
// Only allow dragging to the right (to close)
if (deltaX > 0 && menuRef.current) {
menuRef.current.style.transform = `translateX(${deltaX}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (!isDragging.current) return;
isDragging.current = false;
const deltaX = touchCurrentX.current - touchStartX.current;
const threshold = 100; // Minimum swipe distance to close
if (menuRef.current) {
menuRef.current.style.transform = '';
if (deltaX > threshold) {
setMobileMenuOpen(false);
}
}
}, []);
const closeMenu = useCallback(() => {
setMobileMenuOpen(false);
}, []);
const navLinks = [
{ href: '/', label: t('nav.home') },
{ href: '/events', label: t('nav.events') },
{ href: '/community', label: t('nav.community') },
{ href: '/contact', label: t('nav.contact') },
];
return (
<header className="sticky top-0 z-50 bg-white shadow-sm">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center">
<Image
src="/images/logo-spanglish.png"
alt="Spanglish"
width={140}
height={40}
className="h-10 w-auto"
priority
/>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<NavLink key={link.href} href={link.href}>
{link.label}
</NavLink>
))}
</div>
{/* Right side actions */}
<div className="hidden md:flex items-center gap-4">
<LanguageToggle />
{user ? (
<div className="flex items-center gap-3">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
{t('nav.dashboard')}
</Button>
</Link>
{hasAdminAccess && (
<Link href="/admin">
<Button variant="ghost" size="sm">
{t('nav.admin')}
</Button>
</Link>
)}
<span className="text-sm text-gray-600">{user.name}</span>
<Button variant="outline" size="sm" onClick={logout}>
{t('nav.logout')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
{t('nav.login')}
</Button>
</Link>
<Link href="/events">
<Button size="sm">
{t('nav.joinEvent')}
</Button>
</Link>
</div>
)}
</div>
{/* Mobile menu button (hamburger) */}
<button
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<Bars3Icon className="w-6 h-6" style={{ color: '#002F44' }} />
</button>
</div>
</nav>
{/* Mobile Slide-in Menu */}
{/* Overlay */}
<div
className={`
fixed inset-0 bg-black/50 z-40 md:hidden
transition-opacity duration-300 ease-in-out
${mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
onClick={closeMenu}
aria-hidden="true"
/>
{/* Slide-in Panel */}
<div
ref={menuRef}
className={`
fixed top-0 right-0 h-full w-[280px] max-w-[85vw] bg-white z-50 md:hidden
shadow-xl transform transition-transform duration-300 ease-in-out
${mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}
`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Menu Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<Image
src="/images/logo-spanglish.png"
alt="Spanglish"
width={100}
height={28}
className="h-7 w-auto"
/>
<button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={closeMenu}
aria-label="Close menu"
>
<XMarkIcon className="w-6 h-6" style={{ color: '#002F44' }} />
</button>
</div>
{/* Menu Content */}
<div className="flex flex-col h-[calc(100%-65px)] overflow-y-auto">
{/* Navigation Links */}
<nav className="py-4">
{navLinks.map((link) => (
<MobileNavLink
key={link.href}
href={link.href}
onClick={closeMenu}
>
{link.label}
</MobileNavLink>
))}
</nav>
{/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Language Toggle */}
<div className="px-6 py-4">
<LanguageToggle variant="buttons" />
</div>
{/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Auth Actions */}
<div className="px-6 py-4 flex flex-col gap-3 mt-auto">
{user ? (
<>
<div className="text-sm text-gray-500 mb-2 flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[#002F44] flex items-center justify-center text-white text-sm font-medium">
{user.name?.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-[#002F44]">{user.name}</span>
</div>
<Link href="/dashboard" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.dashboard')}
</Button>
</Link>
{hasAdminAccess && (
<Link href="/admin" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.admin')}
</Button>
</Link>
)}
<Button
variant="secondary"
onClick={() => {
logout();
closeMenu();
}}
className="w-full justify-center"
>
{t('nav.logout')}
</Button>
</>
) : (
<>
<Link href="/login" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.login')}
</Button>
</Link>
<Link href="/events" onClick={closeMenu}>
<Button className="w-full justify-center">
{t('nav.joinEvent')}
</Button>
</Link>
</>
)}
</div>
{/* Swipe hint */}
<div className="px-6 pb-6 pt-2">
<p className="text-xs text-gray-400 text-center">
Swipe right to close
</p>
</div>
</div>
</div>
</header>
);
}