You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
473 lines
20 KiB
TypeScript
473 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
import useTranslation from '../hooks/useTranslation';
|
|
import Link from 'next/link';
|
|
import { Menu, X, BarChart3, LogOut, Zap, Users, Settings, Calendar, User, HelpCircle, Globe, ChevronDown } from 'lucide-react';
|
|
import { useUserSettings } from '@/src/contexts/UserSettingsContext';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useSwissOIDAuth } from 'swissoid-front';
|
|
import PartnerSearchModal from './PartnerSearchModal';
|
|
import ClaimCredentialsModal from './ClaimCredentialsModal';
|
|
import { getClaimedRemoteMember } from '@/src/utils/hasClaimedAnyCredentials';
|
|
import { useLocalizedLink } from '@/src/hooks/useLocalizedLink';
|
|
import { cleanName } from '@/src/utils/bookingUtils';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
import { i18n } from '@/i18n-config';
|
|
|
|
interface NavigationProps {
|
|
pageTitle?: string;
|
|
}
|
|
|
|
export default function Navigation({ pageTitle }: NavigationProps) {
|
|
const { user, login, logout } = useSwissOIDAuth();
|
|
const isLoggedIn = !!user;
|
|
const { t } = useTranslation();
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const { settings, appUser, remoteMembers } = useUserSettings();
|
|
const [showPartnerModal, setShowPartnerModal] = useState(false);
|
|
const [showClaimModal, setShowClaimModal] = useState(false);
|
|
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
|
|
const { locale, localizedLink } = useLocalizedLink();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const menuScrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Get the current user's name from remote members
|
|
const currentUserData = useMemo(() => {
|
|
if (!settings || !remoteMembers) return null;
|
|
const claimedMember = getClaimedRemoteMember(settings);
|
|
if (!claimedMember) return null;
|
|
const member = remoteMembers.find(m => m.remote_member_id === claimedMember.remote_member_id);
|
|
if (!member) return null;
|
|
return {
|
|
name: cleanName(member.full_account_str),
|
|
initials: cleanName(member.full_account_str).split(' ').map(n => n[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join('')
|
|
};
|
|
}, [settings, remoteMembers]);
|
|
|
|
|
|
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
|
const closeMenu = () => {
|
|
setIsMenuOpen(false);
|
|
// Multiple fallbacks to ensure menu closes even on fast navigation
|
|
setTimeout(() => setIsMenuOpen(false), 0);
|
|
setTimeout(() => setIsMenuOpen(false), 50);
|
|
setTimeout(() => setIsMenuOpen(false), 100);
|
|
};
|
|
|
|
// Language options
|
|
const languages = [
|
|
{ code: 'en-US', label: 'English', flag: '🇺🇸' },
|
|
{ code: 'de-DE', label: 'Deutsch', flag: '🇩🇪' },
|
|
{ code: 'fr-CH', label: 'Français', flag: '🇨🇭' }
|
|
];
|
|
|
|
// Handle language change
|
|
const handleLanguageChange = (newLocale: string) => {
|
|
// Get the current pathname without the locale
|
|
const segments = pathname.split('/');
|
|
const currentLocale = segments[1];
|
|
|
|
// Check if the first segment is a locale
|
|
if (i18n.locales.includes(currentLocale as any)) {
|
|
segments[1] = newLocale;
|
|
} else {
|
|
// If no locale in path, add it
|
|
segments.unshift('', newLocale);
|
|
}
|
|
|
|
const newPath = segments.join('/').replace(/\/+/g, '/');
|
|
router.push(newPath);
|
|
};
|
|
|
|
// Handle language dropdown toggle with scroll
|
|
const handleLanguageDropdownToggle = () => {
|
|
const newState = !isLanguageDropdownOpen;
|
|
setIsLanguageDropdownOpen(newState);
|
|
|
|
// If opening the dropdown, scroll to bottom of menu
|
|
if (newState && menuScrollContainerRef.current) {
|
|
setTimeout(() => {
|
|
const container = menuScrollContainerRef.current;
|
|
if (container) {
|
|
container.scrollTo({
|
|
top: container.scrollHeight,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
// Prevent body scrolling when menu is open
|
|
useEffect(() => {
|
|
if (isMenuOpen) {
|
|
// Save current scroll position
|
|
const scrollY = window.scrollY;
|
|
|
|
// Add styles to prevent scrolling
|
|
document.body.style.position = 'fixed';
|
|
document.body.style.top = `-${scrollY}px`;
|
|
document.body.style.width = '100%';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
return () => {
|
|
// Restore scroll position when menu closes
|
|
document.body.style.position = '';
|
|
document.body.style.top = '';
|
|
document.body.style.width = '';
|
|
document.body.style.overflow = '';
|
|
window.scrollTo(0, scrollY);
|
|
};
|
|
}
|
|
}, [isMenuOpen]);
|
|
|
|
const menuVariants = {
|
|
closed: {
|
|
x: '100%',
|
|
transition: {
|
|
type: 'spring' as const,
|
|
stiffness: 400,
|
|
damping: 40
|
|
}
|
|
},
|
|
open: {
|
|
x: 0,
|
|
transition: {
|
|
type: 'spring' as const,
|
|
stiffness: 400,
|
|
damping: 40
|
|
}
|
|
}
|
|
};
|
|
|
|
const overlayVariants = {
|
|
closed: { opacity: 0 },
|
|
open: { opacity: 1 }
|
|
};
|
|
|
|
const menuItemVariants = {
|
|
closed: { x: 50, opacity: 0 },
|
|
open: (i: number) => ({
|
|
x: 0,
|
|
opacity: 1,
|
|
transition: {
|
|
delay: i * 0.1,
|
|
type: 'spring' as const,
|
|
stiffness: 400,
|
|
damping: 25
|
|
}
|
|
})
|
|
};
|
|
|
|
const bookingLink = localizedLink(
|
|
settings?.default_remote_sport.facility_slug && settings?.default_remote_sport.sport_slug
|
|
? `/booking/${encodeURIComponent(settings.default_remote_sport.facility_slug)}/${encodeURIComponent(settings.default_remote_sport.sport_slug)}/today`
|
|
: '/select-remote'
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<header className="bg-white shadow-sm border-b border-slate-200 sticky top-0 z-50">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between h-16">
|
|
{/* Logo */}
|
|
<div className="flex items-center">
|
|
<Link href={localizedLink("/")} className="flex items-center group">
|
|
<div className="relative">
|
|
<h1 className="text-2xl font-bold text-slate-900">
|
|
Playchoo
|
|
</h1>
|
|
<div className="absolute -bottom-1 left-0 w-0 h-0.5 bg-slate-900 group-hover:w-full transition-all duration-300"></div>
|
|
</div>
|
|
</Link>
|
|
{pageTitle && (
|
|
<span className="text-xl text-slate-600 font-normal ml-3 pl-3 border-l-2 border-slate-300">{pageTitle}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right side navigation */}
|
|
<div className="flex items-center space-x-4">
|
|
{/* Always show hamburger on mobile, show login/register on desktop when logged out */}
|
|
<div className="hidden sm:flex items-center space-x-3">
|
|
{!isLoggedIn && (
|
|
<>
|
|
<button
|
|
onClick={() => login()}
|
|
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors duration-200"
|
|
>
|
|
{t('Login')}
|
|
</button>
|
|
<button
|
|
onClick={() => login()}
|
|
className="px-4 py-2 text-sm font-medium bg-slate-900 text-white rounded-lg hover:bg-slate-800 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
>
|
|
{t('Register')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* Hamburger menu button - always visible on mobile, only for logged-in users on desktop */}
|
|
<button
|
|
onClick={toggleMenu}
|
|
className={`relative p-2 rounded-xl bg-slate-900 text-white shadow-lg hover:shadow-xl hover:bg-slate-800 transition-all duration-200 group ${!isLoggedIn ? 'block sm:hidden' : 'block'}`}
|
|
>
|
|
<motion.div
|
|
animate={{ rotate: isMenuOpen ? 180 : 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
{isMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
|
</motion.div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile/Desktop Side Menu */}
|
|
<AnimatePresence>
|
|
{isMenuOpen && (
|
|
<>
|
|
{/* Overlay */}
|
|
<motion.div
|
|
variants={overlayVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="closed"
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
|
onClick={closeMenu}
|
|
/>
|
|
|
|
{/* Side Menu */}
|
|
<motion.div
|
|
variants={menuVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
exit="closed"
|
|
className="fixed top-0 right-0 h-full w-full sm:w-96 bg-white shadow-2xl z-50 flex flex-col"
|
|
>
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-slate-200 p-6 flex-shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
{isLoggedIn && appUser && currentUserData ? (
|
|
<Link
|
|
href={localizedLink(`/profile/user/${appUser.app_user_id}/${settings?.default_remote_sport.sport_slug || ''}`)}
|
|
onClick={closeMenu}
|
|
className="flex items-center space-x-3 group"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-slate-900 flex items-center justify-center text-white font-semibold text-sm">
|
|
{currentUserData.initials}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-slate-900 group-hover:text-slate-700 transition-colors">
|
|
{currentUserData.name}
|
|
</div>
|
|
<div className="text-xs text-slate-500">{t('View Profile')}</div>
|
|
</div>
|
|
</Link>
|
|
) : (
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-900">{t('Menu')}</h2>
|
|
<p className="text-slate-500 text-sm">{t('Navigate your game')}</p>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={closeMenu}
|
|
className="p-2 pl-6 rounded-lg hover:bg-slate-50 transition-colors duration-200 text-slate-600"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu Items */}
|
|
<div ref={menuScrollContainerRef} className="flex-1 p-6 space-y-2 overflow-y-auto">
|
|
{!isLoggedIn ? (
|
|
<>
|
|
<motion.div
|
|
custom={0}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
>
|
|
<button
|
|
onClick={() => { login(); closeMenu(); }}
|
|
className="flex items-center space-x-3 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200 group w-full"
|
|
>
|
|
<div className="p-2 rounded-lg bg-slate-100 group-hover:bg-slate-200 transition-colors duration-200">
|
|
<User className="w-5 h-5 text-slate-700" />
|
|
</div>
|
|
<span className="font-medium text-slate-900">{t('Login')}</span>
|
|
</button>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
custom={1}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
>
|
|
<button
|
|
onClick={() => { login(); closeMenu(); }}
|
|
className="flex items-center space-x-3 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200 group w-full"
|
|
>
|
|
<div className="p-2 rounded-lg bg-slate-100 group-hover:bg-slate-200 transition-colors duration-200">
|
|
<Users className="w-5 h-5 text-slate-700" />
|
|
</div>
|
|
<span className="font-medium text-slate-900">{t('Register')}</span>
|
|
</button>
|
|
</motion.div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<motion.div
|
|
custom={0}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
>
|
|
<Link
|
|
href={localizedLink("/admin/facilities")}
|
|
onClick={closeMenu}
|
|
className="flex items-center space-x-3 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200 group"
|
|
>
|
|
<div className="p-2 rounded-lg bg-purple-100 group-hover:bg-purple-200 transition-colors duration-200">
|
|
<Settings className="w-5 h-5 text-purple-700" />
|
|
</div>
|
|
<span className="font-medium text-slate-900">{t('Venue Management')}</span>
|
|
</Link>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
|
|
{/* Language Selector - Available for all users */}
|
|
<div className="my-4 border-t border-slate-100"></div>
|
|
|
|
<motion.div
|
|
custom={isLoggedIn ? 1 : 2}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
>
|
|
<div className="px-4 py-2">
|
|
{/* Language Dropdown Header */}
|
|
<button
|
|
onClick={handleLanguageDropdownToggle}
|
|
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 transition-colors duration-200"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<Globe className="w-4 h-4 text-slate-600" />
|
|
<span className="font-medium text-slate-700">{t('Language')}</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-xl">{languages.find(l => l.code === locale)?.flag}</span>
|
|
<span className="font-medium text-slate-700">{languages.find(l => l.code === locale)?.label}</span>
|
|
<ChevronDown className={`w-4 h-4 text-slate-500 transition-transform duration-200 ${isLanguageDropdownOpen ? 'rotate-180' : ''}`} />
|
|
</div>
|
|
</button>
|
|
|
|
{/* Language Dropdown Options */}
|
|
<AnimatePresence>
|
|
{isLanguageDropdownOpen && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="mt-2 space-y-1 pl-7">
|
|
{languages.filter(lang => lang.code !== locale).map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
onClick={() => {
|
|
handleLanguageChange(lang.code);
|
|
setIsLanguageDropdownOpen(false);
|
|
closeMenu();
|
|
}}
|
|
className="w-full flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-slate-100 text-slate-700 transition-colors duration-200"
|
|
>
|
|
<span className="text-xl">{lang.flag}</span>
|
|
<span className="font-medium">{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t-2 border-slate-200 p-6 space-y-4 flex-shrink-0">
|
|
{isLoggedIn && (
|
|
<div className="flex items-center gap-3">
|
|
<motion.div
|
|
custom={2}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
className="flex-1"
|
|
>
|
|
<Link
|
|
href={localizedLink("/logout")}
|
|
onClick={closeMenu}
|
|
className="flex items-center space-x-3 p-4 rounded-xl hover:bg-red-50 transition-colors duration-200 group text-red-600"
|
|
>
|
|
<div className="p-2 rounded-lg bg-red-100 text-red-600 group-hover:bg-red-200 transition-colors duration-200">
|
|
<LogOut className="w-5 h-5" />
|
|
</div>
|
|
<span className="font-medium">{t('Logout')}</span>
|
|
</Link>
|
|
</motion.div>
|
|
<motion.div
|
|
custom={3}
|
|
variants={menuItemVariants}
|
|
initial="closed"
|
|
animate="open"
|
|
>
|
|
<Link
|
|
href={localizedLink("/settings")}
|
|
onClick={closeMenu}
|
|
className="flex items-center justify-center w-12 h-12 rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors duration-200"
|
|
title={t('Settings')}
|
|
>
|
|
<Settings className="w-5 h-5 text-slate-600" />
|
|
</Link>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
|
|
<span>Version {process.env.NEXT_PUBLIC_APP_VERSION}</span>
|
|
<div className="flex items-center space-x-1">
|
|
<Zap className="w-4 h-4 text-slate-400" />
|
|
<span>{t('Powered by passion')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
<PartnerSearchModal
|
|
isOpen={showPartnerModal}
|
|
onClose={() => setShowPartnerModal(false)}
|
|
onSelectPartner={() => {}}
|
|
remoteSlug={settings?.default_remote_sport.facility_slug || ''}
|
|
sportSlug={settings?.default_remote_sport.sport_slug || ''}
|
|
selectedPartners={[]}
|
|
context="lookup"
|
|
/>
|
|
<ClaimCredentialsModal
|
|
isOpen={showClaimModal}
|
|
onClose={() => setShowClaimModal(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|