This commit is contained in:
Andras Schmelczer 2026-05-31 20:20:41 +01:00
parent 8688b7475e
commit e8345cbdc1
40 changed files with 1980 additions and 904 deletions

View file

@ -81,6 +81,15 @@ function isProtectedPage(page: Page): boolean {
return page === 'account' || page === 'saved';
}
function isSharedDashboardUrl(): boolean {
const share = new URLSearchParams(window.location.search).get('share');
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
}
function isAuthRequiredRoute(page: Page): boolean {
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
}
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
const normalizedHash = normalizeHash(hash);
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
@ -235,6 +244,7 @@ export default function App() {
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
const authCompletedRef = useRef(false);
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
const [dashboardReady, setDashboardReady] = useState(false);
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
// calls the current implementation without re-running when the callback identity changes.
@ -266,7 +276,7 @@ export default function App() {
if (!completed) {
setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = null;
if (isProtectedPage(activePageRef.current)) {
if (isAuthRequiredRoute(activePageRef.current)) {
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash('');
setActivePage('home');
@ -517,7 +527,10 @@ export default function App() {
}
}, [activePage, fetchSearches]);
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved';
const isAuthRequiredPage =
activePage === 'account' ||
activePage === 'saved' ||
(activePage === 'dashboard' && !mapUrlState.share);
useEffect(() => {
if (authLoading) return;
if (isAuthRequiredPage && !user) {
@ -530,6 +543,13 @@ export default function App() {
const [exportState, setExportState] = useState<ExportState | null>(null);
useEffect(() => {
if (activePage !== 'dashboard' || !user) {
setDashboardReady(false);
setExportState(null);
}
}, [activePage, user]);
if ((isScreenshotMode || isOgMode) && inviteCode) {
return (
<Suspense fallback={<PageFallback />}>
@ -584,8 +604,9 @@ export default function App() {
onPageChange={navigateTo}
theme={theme}
onToggleTheme={toggleTheme}
exportState={activePage === 'dashboard' ? exportState : null}
exportState={activePage === 'dashboard' && user ? exportState : null}
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady}
onSaveSearch={
activePage === 'dashboard' && user
? editingSearch
@ -675,6 +696,7 @@ export default function App() {
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
onDashboardParamsChange={setDashboardParams}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}
initialPostcode={mapUrlState.postcode}

View file

@ -461,6 +461,24 @@ interface ShareLinkListItem {
created: string;
}
function latestPendingInviteUrls(invites: InviteListItem[]): Record<string, string> {
const latestByType: Record<string, { url: string; createdMs: number }> = {};
for (const invite of invites) {
if (invite.used || !invite.url) continue;
const createdMs = Date.parse(invite.created) || 0;
const existing = latestByType[invite.invite_type];
if (!existing || createdMs > existing.createdMs) {
latestByType[invite.invite_type] = { url: invite.url, createdMs };
}
}
return Object.fromEntries(
Object.entries(latestByType).map(([type, invite]) => [type, invite.url])
);
}
function InviteTable({
invites,
loading,
@ -673,7 +691,16 @@ function InviteSection({ user }: { user: AuthUser }) {
const res = await fetch(apiUrl('invites'), authHeaders());
assertOk(res, 'Fetch invites');
const data = await res.json();
setInviteHistory(data.invites);
const invites: InviteListItem[] = Array.isArray(data.invites) ? data.invites : [];
setInviteHistory(invites);
const pendingInviteUrls = latestPendingInviteUrls(invites);
setInviteUrl((prev) => {
const next = { ...prev };
for (const [type, url] of Object.entries(pendingInviteUrls)) {
if (!next[type]) next[type] = url;
}
return next;
});
} catch {
// Silent — non-critical
} finally {

View file

@ -8,8 +8,11 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key,
t: (key: string) => {
if (key === 'locationSearch.placeholder') return 'Search places or postcodes...';
if (key === 'locationSearch.noResults') return 'No matching places or postcodes';
return key;
},
}),
}));
@ -226,6 +229,91 @@ describe('LocationSearch', () => {
);
});
it('selects the first place suggestion with Enter when none is highlighted', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
if (url.pathname === '/api/places') {
return Promise.resolve(
jsonResponse({
places: [
{
type: 'place',
name: 'London',
slug: 'london',
place_type: 'city',
lat: 51.5074,
lon: -0.1278,
},
],
postcodes: [],
addresses: [],
})
);
}
if (url.pathname === '/api/nearest-postcode') {
return Promise.resolve(
jsonResponse({
postcode: 'SW1A 1AA',
latitude: 51.501,
longitude: -0.141,
geometry: postcodeGeometry,
})
);
}
return Promise.resolve(new Response(null, { status: 404 }));
})
);
const onFlyTo = vi.fn();
const onLocationSearched = vi.fn();
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'London' } });
await screen.findByRole('button', { name: 'London' });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
expect(onLocationSearched).toHaveBeenCalledTimes(1);
});
expect(onFlyTo).toHaveBeenCalledWith(51.5074, -0.1278, 10);
expect(onLocationSearched).toHaveBeenCalledWith({
postcode: 'SW1A 1AA',
geometry: postcodeGeometry,
latitude: 51.501,
longitude: -0.141,
zoom: 10,
markerLatitude: 51.5074,
markerLongitude: -0.1278,
});
});
it('shows an empty state for invalid place queries', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve(
jsonResponse({
places: [],
postcodes: [],
addresses: [],
})
)
)
);
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '!!!!zzzzzz!!!!' } });
await waitFor(() => {
expect(screen.getByText('No matching places or postcodes')).toBeTruthy();
});
});
it('keeps only the three most recent local searches', async () => {
vi.stubGlobal(
'fetch',

View file

@ -333,6 +333,8 @@ export default function LocationSearch({
onSelect={selectResult}
loading={loading}
placeholder={t('locationSearch.placeholder')}
ariaLabel={t('locationSearch.searchLabel')}
name="location-search"
size="sm"
inputClassName={
inputClassName ??

View file

@ -91,6 +91,7 @@ export default function MapPage({
onNavigateTo,
onExportStateChange,
onDashboardParamsChange,
onDashboardReadyChange,
screenshotMode,
ogMode,
isMobile = false,
@ -642,6 +643,23 @@ export default function MapPage({
onDashboardParamsChange?.(dashboardParams);
}, [dashboardParams, onDashboardParamsChange]);
const dashboardReady =
!initialLoading &&
!mapData.loading &&
!mapData.licenseRequired &&
mapData.bounds != null &&
mapData.currentView != null;
useEffect(() => {
onDashboardReadyChange?.(dashboardReady);
}, [dashboardReady, onDashboardReadyChange]);
useEffect(() => {
return () => {
onDashboardReadyChange?.(false);
};
}, [onDashboardReadyChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
@ -830,8 +848,8 @@ export default function MapPage({
</button>
<button
onClick={() => onUpdateEdit?.(dashboardParams)}
disabled={savingSearch}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
disabled={savingSearch || !dashboardReady}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>

View file

@ -186,7 +186,7 @@ export default function POIPane({
</div>
{!isCollapsed && (
<div className="px-3 py-2">
<PillGroup>
<PillGroup wrap>
{group.categories.map((category) => {
const logo = getPoiCategoryLogoUrl(category);
return (

View file

@ -269,7 +269,10 @@ export function DesktopMapPage({
</div>
)}
{poiPaneOpen && (
<div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div
className="absolute bottom-28 right-4 z-10 flex min-h-0 w-80 max-w-[calc(100%_-_2rem)] flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
style={{ height: 'min(30rem, calc(100vh - 10rem))' }}
>
{poiPane}
</div>
)}

View file

@ -132,6 +132,10 @@ export function MobileMapPage({
upgradeModal,
editingBar,
}: MobileMapPageProps) {
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
bottomScreenInset
)}px - 7rem))`;
return (
<div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
@ -219,7 +223,13 @@ export function MobileMapPage({
)}
{poiPaneOpen && (
<div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div
className="absolute top-24 right-3 left-3 z-20 flex min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
style={{
height: `min(22rem, ${floatingPaneAvailableHeight})`,
maxHeight: floatingPaneAvailableHeight,
}}
>
{poiPane}
</div>
)}

View file

@ -39,6 +39,7 @@ export interface MapPageProps {
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
onDashboardParamsChange?: (params: string) => void;
onDashboardReadyChange?: (ready: boolean) => void;
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
@ -36,6 +36,9 @@ export default function AuthModal({
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
const dialogRef = useModalA11y();
const fieldId = useId();
const emailInputId = `${fieldId}-email`;
const passwordInputId = `${fieldId}-password`;
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
@ -194,14 +197,20 @@ export default function AuthModal({
{/* Email form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
<label
htmlFor={emailInputId}
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
>
{t('auth.email')}
</label>
<input
id={emailInputId}
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('auth.emailPlaceholder')}
/>
@ -209,15 +218,21 @@ export default function AuthModal({
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
<label
htmlFor={passwordInputId}
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
>
{t('auth.password')}
</label>
<input
id={passwordInputId}
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={
view === 'register'

View file

@ -78,6 +78,7 @@ export default function Header({
onToggleTheme,
exportState,
dashboardParams,
dashboardActionsDisabled = false,
onSaveSearch,
savingSearch,
editingSearch,
@ -96,6 +97,7 @@ export default function Header({
onToggleTheme: () => void;
exportState: HeaderExportState | null;
dashboardParams: string;
dashboardActionsDisabled?: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
editingSearch: EditingSearchState | null;
@ -116,6 +118,7 @@ export default function Header({
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
);
const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth);
const dashboardActionsBlocked = activePage === 'dashboard' && (!user || dashboardActionsDisabled);
useEffect(() => {
const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY);
@ -139,6 +142,10 @@ export default function Header({
if (!useSidebarNav) setMenuOpen(false);
}, [useSidebarNav]);
useEffect(() => {
if (dashboardActionsBlocked) setExportMenuOpen(false);
}, [dashboardActionsBlocked]);
const doCopy = useCallback((text: string) => {
copyToClipboard(text, () => {
setCopied(true);
@ -147,6 +154,7 @@ export default function Header({
}, []);
const handleShare = useCallback(async () => {
if (dashboardActionsBlocked) return;
const params =
activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, '');
if (!params) {
@ -167,7 +175,7 @@ export default function Header({
} finally {
setSharing(false);
}
}, [activePage, dashboardParams, doCopy, i18n.language]);
}, [activePage, dashboardActionsBlocked, dashboardParams, doCopy, i18n.language]);
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
@ -206,8 +214,8 @@ export default function Header({
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
disabled={savingSearch || dashboardActionsBlocked}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
@ -216,14 +224,16 @@ export default function Header({
</div>
)}
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<div className="flex min-w-0 items-center gap-4">
<a
href="/"
className="flex cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity"
className="flex min-w-0 cursor-pointer 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>
<LogoIcon className="w-5 h-5 shrink-0 text-teal-400" />
<span className="max-w-[9rem] truncate whitespace-nowrap text-lg font-semibold text-teal-300 sm:max-w-none">
{t('header.appName')}
</span>
</a>
{/* Desktop nav */}
@ -266,14 +276,14 @@ export default function Header({
</div>
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
<div className="ml-auto flex shrink-0 items-center gap-2">
{/* Desktop-only dashboard actions */}
{!useSidebarNav && activePage === 'dashboard' && (
{!useSidebarNav && activePage === 'dashboard' && user && (
<>
<button
onClick={handleShare}
disabled={sharing}
className="flex cursor-pointer 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={sharing || dashboardActionsBlocked}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
{sharing ? (
<>
@ -295,8 +305,8 @@ export default function Header({
{exportState && (
<button
onClick={() => setExportMenuOpen(true)}
disabled={exportState.exporting}
className="flex cursor-pointer 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={exportState.exporting || dashboardActionsBlocked}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
title={t('header.exportToExcel')}
>
<DownloadIcon className="w-4 h-4" />
@ -306,8 +316,8 @@ export default function Header({
{onSaveSearch && !editingSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex cursor-pointer 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={savingSearch || dashboardActionsBlocked}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
@ -363,7 +373,7 @@ export default function Header({
{useSidebarNav && !user && (
<button
onClick={onRegisterClick}
className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
className="flex h-8 max-w-[8.5rem] shrink-0 cursor-pointer items-center justify-center truncate whitespace-nowrap rounded bg-teal-600 px-2.5 text-xs font-semibold leading-none transition-colors hover:bg-teal-700 sm:max-w-none sm:px-3 sm:text-sm"
>
{t('header.createAccount')}
</button>
@ -410,6 +420,7 @@ export default function Header({
onToggleTheme={onToggleTheme}
exportState={exportState}
onOpenExportMenu={() => setExportMenuOpen(true)}
dashboardActionsDisabled={dashboardActionsBlocked}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
isEditingSearch={!!editingSearch}

View file

@ -20,6 +20,7 @@ interface MobileMenuProps {
onToggleTheme: () => void;
exportState: HeaderExportState | null;
onOpenExportMenu: () => void;
dashboardActionsDisabled?: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
@ -41,6 +42,7 @@ export default function MobileMenu({
onToggleTheme,
exportState,
onOpenExportMenu,
dashboardActionsDisabled = false,
onSaveSearch,
savingSearch,
isEditingSearch,
@ -101,7 +103,7 @@ export default function MobileMenu({
</a>
);
const dashboardActions = activePage === 'dashboard' && (
const dashboardActions = activePage === 'dashboard' && user && (
<div className="px-2 py-2 border-b border-navy-700">
<div className="grid grid-cols-2 gap-2">
<button
@ -109,7 +111,7 @@ export default function MobileMenu({
onShare();
onClose();
}}
disabled={sharing}
disabled={sharing || dashboardActionsDisabled}
className={dashboardActionClass}
>
{sharing ? (
@ -127,8 +129,8 @@ export default function MobileMenu({
onClose();
onOpenExportMenu();
}}
disabled={exportState.exporting}
className={dashboardActionClass}
disabled={exportState.exporting || dashboardActionsDisabled}
>
<DownloadIcon className="w-4 h-4" />
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
@ -140,7 +142,7 @@ export default function MobileMenu({
onSaveSearch();
onClose();
}}
disabled={savingSearch}
disabled={savingSearch || dashboardActionsDisabled}
className={dashboardActionClass}
>
{savingSearch ? (
@ -199,7 +201,7 @@ export default function MobileMenu({
</button>
{/* Language selector */}
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide">
<div className="grid max-w-full grid-cols-3 gap-1 px-3 pb-1">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
@ -208,7 +210,7 @@ export default function MobileMenu({
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 ${
className={`flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1.5 text-sm ${
i18n.language === lang.code
? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white'

View file

@ -3,12 +3,17 @@ import type { ReactNode } from 'react';
interface PillGroupProps {
children: ReactNode;
className?: string;
wrap?: boolean;
}
export function PillGroup({ children, className = '' }: PillGroupProps) {
export function PillGroup({ children, className = '', wrap = false }: PillGroupProps) {
return (
<div
className={`flex min-w-0 max-w-full flex-nowrap gap-1.5 overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible ${className}`}
className={`flex min-w-0 max-w-full gap-1.5 ${
wrap
? 'flex-wrap overflow-x-visible'
: 'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible'
} ${className}`}
>
{children}
</div>

View file

@ -1,5 +1,6 @@
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
@ -13,6 +14,7 @@ interface SearchHook {
activeIndex: number;
setActiveIndex: (idx: number) => void;
open: boolean;
searching?: boolean;
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
@ -23,6 +25,8 @@ interface PlaceSearchInputProps {
onSelect: (result: SearchResult) => void;
loading?: boolean;
placeholder?: string;
ariaLabel?: string;
name?: string;
size?: 'sm' | 'xs';
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
@ -35,19 +39,28 @@ export function PlaceSearchInput({
onSelect,
loading,
placeholder,
ariaLabel,
name,
size = 'sm',
inputClassName,
inputRef,
onInputChange,
portal,
}: PlaceSearchInputProps) {
const { t } = useTranslation();
const sm = size === 'sm';
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
const showDropdown = search.open && search.results.length > 0;
const showEmptyResults =
search.open &&
!search.searching &&
search.query.trim().length >= 2 &&
search.results.length === 0;
const showDropdown = search.open && (search.results.length > 0 || showEmptyResults);
const showSpinner = loading || search.searching;
const dropdown = showDropdown && (
<div
@ -64,57 +77,66 @@ export function PlaceSearchInput({
: undefined
}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: result.type === 'address'
? `addr-${result.postcode}-${result.address}-${result.lat}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
{showEmptyResults ? (
<div
className={`text-warm-500 dark:text-warm-400 ${sm ? 'px-3 py-2 text-sm' : 'px-2 py-1.5 text-xs'}`}
role="status"
>
{result.type === 'postcode' ? (
<>
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
{t('locationSearch.noResults')}
</div>
) : (
search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: result.type === 'address'
? `addr-${result.postcode}-${result.address}-${result.lat}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
>
{result.type === 'postcode' ? (
<>
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
</span>
</span>
</>
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
)}
</span>
</>
)}
</button>
))}
</>
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
)}
</span>
</>
)}
</button>
))
)}
</div>
);
@ -123,6 +145,7 @@ export function PlaceSearchInput({
<input
ref={inputRef}
type="text"
name={name}
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
@ -132,11 +155,12 @@ export function PlaceSearchInput({
search.showEmptySearches();
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder}
placeholder={placeholder}
className={inputClassName}
/>
{loading && (
{showSpinner && (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
/>

View file

@ -4,18 +4,27 @@ interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
ariaLabel?: string;
className?: string;
}
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
export function SearchInput({
value,
onChange,
placeholder,
ariaLabel,
className = '',
}: SearchInputProps) {
const { t } = useTranslation();
const inputPlaceholder = placeholder ?? t('common.search');
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t('common.search')}
placeholder={inputPlaceholder}
aria-label={ariaLabel ?? inputPlaceholder}
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
/>
);

View file

@ -13,6 +13,7 @@ const LISTING_CLUSTER_MAX_ZOOM = 24;
const LISTING_CLUSTER_POPUP_LIMIT = 30;
const LISTING_SPIDERFY_LIMIT = 12;
const TILE_SIZE = 512;
const PRICE_LABEL_CHARACTER_SET = '£0123456789.kM';
interface SingleListingPopupInfo {
mode: 'single';
@ -472,6 +473,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
outlineWidth: 3,
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
fontSettings: { sdf: true },
characterSet: PRICE_LABEL_CHARACTER_SET,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,

View file

@ -160,6 +160,7 @@ export function useLocationSearch(mode?: string) {
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestQueryRef = useRef('');
@ -176,6 +177,7 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setSearching(false);
setResults(recentSearches);
lastResultsRef.current = [];
setOpen(recentSearches.length > 0);
@ -183,6 +185,7 @@ export function useLocationSearch(mode?: string) {
}
if (!mode && looksLikePostcode(trimmed)) {
setSearching(false);
const postcodeResults: SearchResult[] = [
{ type: 'postcode', label: normalizePostcode(trimmed) },
];
@ -192,6 +195,7 @@ export function useLocationSearch(mode?: string) {
}
if (trimmed.length < 2) {
setSearching(false);
setResults([]);
setOpen(false);
return;
@ -200,6 +204,7 @@ export function useLocationSearch(mode?: string) {
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
setResults(locallyFilteredResults);
setOpen(locallyFilteredResults.length > 0);
setSearching(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
@ -211,7 +216,13 @@ export function useLocationSearch(mode?: string) {
`/api/places?${params}`,
authHeaders({ signal: controller.signal })
);
if (!res.ok) return;
if (!res.ok) {
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setResults([]);
setOpen(true);
}
return;
}
const json: {
places: PlaceResult[];
postcodes?: string[];
@ -253,9 +264,17 @@ export function useLocationSearch(mode?: string) {
lastResultsRef.current = combinedResults;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
setOpen(matchingResults.length > 0);
setOpen(true);
} catch (err) {
logNonAbortError('places search', err);
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setResults([]);
setOpen(true);
}
} finally {
if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) {
setSearching(false);
}
}
}, 200);
},
@ -264,7 +283,7 @@ export function useLocationSearch(mode?: string) {
const showEmptySearches = useCallback(() => {
if (latestQueryRef.current.trim()) {
setOpen(results.length > 0);
setOpen(results.length > 0 || latestQueryRef.current.trim().length >= 2);
return;
}
@ -278,6 +297,7 @@ export function useLocationSearch(mode?: string) {
const clear = useCallback(() => {
setQuery('');
latestQueryRef.current = '';
setSearching(false);
setResults([]);
lastResultsRef.current = [];
setOpen(false);
@ -308,6 +328,8 @@ export function useLocationSearch(mode?: string) {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < results.length) {
onSelect(results[activeIndex]);
} else if (results.length > 0) {
onSelect(results[0]);
} else if (looksLikePostcode(query)) {
onSelect({ type: 'postcode', label: normalizePostcode(query) });
}
@ -332,6 +354,7 @@ export function useLocationSearch(mode?: string) {
activeIndex,
setActiveIndex,
open,
searching,
setOpen,
handleInputChange,
handleKeyDown,

View file

@ -916,6 +916,7 @@ const de: Translations = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: 'Orte oder Postcodes suchen...',
noResults: 'Keine passenden Orte oder Postcodes',
postcodeNotFound: 'Postcode nicht gefunden',
lookupFailed: 'Suche fehlgeschlagen',
searchLabel: 'Orte oder Postcodes suchen',

View file

@ -892,6 +892,7 @@ const en = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: 'Search places or postcodes...',
noResults: 'No matching places or postcodes',
postcodeNotFound: 'Postcode not found',
lookupFailed: 'Lookup failed',
searchLabel: 'Search places or postcodes',

View file

@ -924,6 +924,7 @@ const fr: Translations = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: 'Rechercher des lieux ou codes postaux...',
noResults: 'Aucun lieu ni code postal correspondant',
postcodeNotFound: 'Code postal introuvable',
lookupFailed: 'Échec de la recherche',
searchLabel: 'Rechercher des lieux ou codes postaux',

View file

@ -876,6 +876,7 @@ const hi: Translations = {
locationSearch: {
placeholder: 'स्थान या पोस्टकोड खोजें...',
noResults: 'कोई मिलती-जुलती जगह या पोस्टकोड नहीं मिला',
postcodeNotFound: 'पोस्टकोड नहीं मिला',
lookupFailed: 'खोज विफल रही',
searchLabel: 'स्थान या पोस्टकोड खोजें',

View file

@ -910,6 +910,7 @@ const hu: Translations = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: 'Helyek vagy irányítószámok keresése...',
noResults: 'Nincs egyező hely vagy irányítószám',
postcodeNotFound: 'Irányítószám nem található',
lookupFailed: 'A keresés sikertelen',
searchLabel: 'Helyek vagy irányítószámok keresése',

View file

@ -850,6 +850,7 @@ const zh: Translations = {
// ── Location Search ────────────────────────────────
locationSearch: {
placeholder: '搜索地点或邮编...',
noResults: '未找到匹配的地点或邮编',
postcodeNotFound: '未找到该邮编',
lookupFailed: '查询失败',
searchLabel: '搜索地点或邮编',