perfect-postcode/frontend/src/components/ui/MobileMenu.tsx
2026-05-17 10:16:30 +01:00

277 lines
9.7 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import type { HeaderExportState, Page } from './Header';
import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
interface MobileMenuProps {
activePage: Page;
activeHash: string;
onPageChange: (page: Page, hash?: string) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
exportState: HeaderExportState | null;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
onClose: () => void;
onShare: () => void;
copied: boolean;
sharing: boolean;
}
export default function MobileMenu({
activePage,
activeHash,
onPageChange,
theme,
onToggleTheme,
exportState,
onSaveSearch,
savingSearch,
isEditingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
onClose,
onShare,
copied,
sharing,
}: MobileMenuProps) {
const { t, i18n } = useTranslation();
const emailParts = user?.email.split('@');
const emailLocal = emailParts?.[0] ?? '';
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
const mobileNavItem = (page: Page, label: string, hash?: string) => {
const isActive =
activePage === page &&
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites');
const href = hash ? `${PAGE_PATHS[page]}#${hash}` : PAGE_PATHS[page];
return (
<a
key={hash ? `${page}-${hash}` : page}
href={href}
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
isActive ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page, hash);
onClose();
}}
>
{label}
</a>
);
};
const dashboardActionClass =
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
const dashboardSavedItem = user && (
<a
href={PAGE_PATHS.saved}
className={dashboardActionClass}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange('saved');
onClose();
}}
>
{t('header.saved')}
</a>
);
const dashboardActions = activePage === 'dashboard' && (
<div className="px-2 py-2 border-b border-navy-700">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
onShare();
onClose();
}}
disabled={sharing}
className={dashboardActionClass}
>
{sharing ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copied ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')}
</button>
{exportState && (
<button
onClick={() => {
exportState.onExport();
onClose();
}}
disabled={exportState.exporting}
className={dashboardActionClass}
>
<DownloadIcon className="w-4 h-4" />
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && (
<button
onClick={() => {
onSaveSearch();
onClose();
}}
disabled={savingSearch}
className={dashboardActionClass}
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
{isEditingSearch ? t('common.update') : t('common.save')}
</button>
)}
{dashboardSavedItem}
</div>
</div>
);
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="mobile-menu-panel fixed top-0 right-0 bottom-0 w-64 bg-navy-900 text-white z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}
className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label={t('header.closeMenu')}
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{dashboardActions}
<nav className="flex-1 flex flex-col gap-0.5 p-2 overflow-y-auto">
{mobileNavItem('home', t('mobileMenu.home'))}
{mobileNavItem('dashboard', t('header.dashboard'))}
{mobileNavItem('learn', t('header.learn'))}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('account', t('header.inviteFriends'), 'invites')}
{user && mobileNavItem('account', t('userMenu.account'))}
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
</nav>
{/* Theme toggle + Auth section at bottom */}
<div className="p-2 border-t border-navy-700 flex flex-col gap-2">
{/* Theme toggle */}
<button
onClick={() => {
onToggleTheme();
}}
className="w-full flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}</span>
</button>
{/* Language selector */}
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
aria-label={lang.label}
onClick={() => {
localStorage.setItem('language', lang.code);
void changeAppLanguage(lang.code);
}}
className={`flex-none min-w-[2.5rem] flex cursor-pointer items-center justify-center gap-1.5 px-2 py-1.5 rounded text-sm ${
i18n.language === lang.code
? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white'
}`}
>
<span className="text-base leading-none">{lang.flag}</span>
<span className="hidden sm:inline">{lang.label}</span>
</button>
))}
</div>
{/* Auth buttons */}
<div>
{user ? (
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<span
className="min-w-0 text-sm text-warm-300 truncate"
aria-label={user.email}
title={user.email}
>
{emailDomain ? (
<>
<span aria-hidden="true">{emailLocal}</span>
<span aria-hidden="true" className="after:content-['@']" />
<span aria-hidden="true">{emailDomain}</span>
</>
) : (
user.email
)}
</span>
<button
onClick={() => {
onLogout();
onClose();
}}
className="shrink-0 cursor-pointer text-sm text-warm-400 hover:text-white"
>
{t('userMenu.logOut')}
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => {
onLoginClick();
onClose();
}}
className="flex-1 cursor-pointer px-3 py-2 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
{t('header.logIn')}
</button>
<button
onClick={() => {
onRegisterClick();
onClose();
}}
className="flex-1 cursor-pointer px-3 py-2 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
{t('header.createAccount')}
</button>
</div>
)}
</div>
</div>
</div>
</>
);
}