358 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|