perfect-postcode/frontend/src/components/ui/Header.tsx
2026-05-05 22:29:28 +01:00

358 lines
11 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
import LanguageDropdown from './LanguageDropdown';
export type Page =
| 'home'
| 'dashboard'
| 'learn'
| 'pricing'
| 'property-price-map'
| 'postcode-property-search'
| 'commute-property-search'
| 'school-property-search'
| 'postcode-checker'
| 'birmingham-property-search'
| 'manchester-property-search'
| 'bristol-property-search'
| 'data-sources'
| 'methodology'
| 'privacy-security'
| 'account'
| 'saved'
| 'invites'
| 'invite';
export const PAGE_PATHS: Record<Page, string> = {
home: '/',
dashboard: '/dashboard',
learn: '/learn',
pricing: '/pricing',
'property-price-map': '/property-price-map',
'postcode-property-search': '/postcode-property-search',
'commute-property-search': '/commute-property-search',
'school-property-search': '/school-property-search',
'postcode-checker': '/postcode-checker',
'birmingham-property-search': '/property-search/birmingham',
'manchester-property-search': '/property-search/manchester',
'bristol-property-search': '/property-search/bristol',
'data-sources': '/data-sources',
methodology: '/methodology',
'privacy-security': '/privacy-security',
saved: '/saved',
invites: '/invites',
account: '/account',
invite: '/invite',
};
export default function Header({
activePage,
onPageChange,
theme,
onToggleTheme,
onExport,
exporting,
onSaveSearch,
savingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
isMobile,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
isMobile: boolean;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// Close menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [menuOpen]);
// Close menu when switching away from mobile
useEffect(() => {
if (!isMobile) setMenuOpen(false);
}, [isMobile]);
const doCopy = useCallback((text: string) => {
copyToClipboard(text, () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
const handleShare = useCallback(async () => {
const params = window.location.search.replace(/^\?/, '');
if (!params) {
doCopy(window.location.href);
return;
}
prewarmScreenshot(params);
setSharing(true);
try {
const shortUrl = await shortenUrl(params);
doCopy(shortUrl);
} catch {
doCopy(window.location.href);
} finally {
setSharing(false);
}
}, [doCopy]);
const navLink = (page: Page, e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page);
};
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
return (
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="text-lg font-semibold text-teal-300">{t('header.appName')}</span>
</a>
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<a
href={PAGE_PATHS.dashboard}
className={tabClass('dashboard')}
onClick={(e) => navLink('dashboard', e)}
>
{t('header.dashboard')}
</a>
{user && (
<a
href={PAGE_PATHS.invites}
className={tabClass('invites')}
onClick={(e) => navLink('invites', e)}
>
{t('header.inviteFriends')}
</a>
)}
<a
href={PAGE_PATHS.learn}
className={tabClass('learn')}
onClick={(e) => navLink('learn', e)}
>
{t('header.learn')}
</a>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<a
href={PAGE_PATHS.pricing}
className={tabClass('pricing')}
onClick={(e) => navLink('pricing', e)}
>
{t('header.pricing')}
</a>
)}
</nav>
)}
</div>
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
{/* Desktop-only dashboard actions */}
{!isMobile && activePage === 'dashboard' && (
<>
<button
onClick={handleShare}
disabled={sharing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{sharing ? (
<>
<SpinnerIcon className="w-4 h-4 animate-spin" />
{t('header.sharing')}
</>
) : copied ? (
<>
<CheckIcon className="w-4 h-4" />
{t('common.copied')}
</>
) : (
<>
<ClipboardIcon className="w-4 h-4" />
{t('common.share')}
</>
)}
</button>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title={t('header.exportToExcel')}
>
<DownloadIcon className="w-4 h-4" />
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
{onSaveSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
{t('common.save')}
</button>
)}
</>
)}
{!isMobile && user && (
<a
href={PAGE_PATHS.saved}
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
{t('header.saved')}
</a>
)}
{/* Desktop-only auth */}
{!isMobile && (
<>
{user ? (
<UserMenu
user={user}
theme={theme}
onToggleTheme={onToggleTheme}
onLogout={onLogout}
onNavigate={onPageChange}
/>
) : (
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('header.logIn')}
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
{t('header.createAccount')}
</button>
</>
)}
</>
)}
{/* Mobile auth CTA (logged out only) */}
{isMobile && !user && (
<button
onClick={onRegisterClick}
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
>
{t('header.createAccount')}
</button>
)}
{/* Language selector (desktop) */}
{!isMobile && <LanguageDropdown />}
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
{!isMobile && !user && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
title={theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
>
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
)}
{/* Mobile hamburger */}
{isMobile && (
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label={t('header.openMenu')}
>
<MenuIcon className="w-6 h-6" />
</button>
)}
</div>
{/* Mobile slide-in menu */}
{isMobile && menuOpen && (
<MobileMenu
activePage={activePage}
onPageChange={onPageChange}
theme={theme}
onToggleTheme={onToggleTheme}
onExport={onExport}
exporting={exporting}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
user={user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onLogout={onLogout}
onClose={() => setMenuOpen(false)}
onShare={handleShare}
copied={copied}
/>
)}
{/* Mobile "Copied" toast */}
{isMobile && copied && (
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<CheckIcon className="w-4 h-4 text-teal-400" />
{t('common.copiedToClipboard')}
</div>
)}
</header>
);
}