This commit is contained in:
Andras Schmelczer 2026-05-17 10:16:30 +01:00
parent 47d89f6fad
commit 017902b8e6
82 changed files with 331466 additions and 54841 deletions

View file

@ -198,7 +198,7 @@ function SavedSearchesTab({
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
onOpen: (id: string, name: string, params: string) => void;
}) {
const { t, i18n } = useTranslation();
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -302,7 +302,7 @@ function SavedSearchesTab({
<div className="flex gap-2 mt-auto">
<button
onClick={() => onOpen(search.params)}
onClick={() => onOpen(search.id, search.name, search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
{t('common.open')}
@ -358,7 +358,7 @@ export function SavedPage({
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
onOpenSearch: (id: string, name: string, params: string) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(

View file

@ -84,7 +84,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Good+ primary schools within 2km',
type: 'numeric',
group: 'Education',
group: 'Schools',
min: 0,
max: 8,
step: 1,
@ -92,7 +92,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Noise (dB)',
type: 'numeric',
group: 'Environment',
group: 'Defining characteristics',
min: 40,
max: 80,
step: 1,

View file

@ -44,7 +44,6 @@ interface AreaPaneProps {
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
onViewProperties: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
@ -82,7 +81,6 @@ export default function AreaPane({
loading,
hexagonId,
isPostcode = false,
onViewProperties,
hexagonLocation,
filters,
unfilteredCount,
@ -100,7 +98,6 @@ export default function AreaPane({
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -275,14 +272,6 @@ export default function AreaPane({
)}
</div>
)}
{canViewProperties && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
{t('areaPane.viewPropertiesShort')}
</button>
)}
</div>
</div>

View file

@ -44,6 +44,8 @@ export default function ExternalSearchLinks({
if (!urls) return null;
const primaryLinkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded bg-teal-600 hover:bg-teal-700 text-white dark:bg-teal-500 dark:text-navy-950 dark:hover:bg-teal-400 font-medium shadow-sm';
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
@ -56,7 +58,12 @@ export default function ExternalSearchLinks({
</h3>
<div className="flex flex-wrap gap-2">
{rightmoveHref ? (
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
<a
href={rightmoveHref}
target="_blank"
rel="noopener noreferrer"
className={primaryLinkClass}
>
Rightmove
</a>
) : (

View file

@ -161,7 +161,7 @@ export default function FeatureBrowser({
title={t('filters.aboutData')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
<InfoIcon className="w-4 h-4" />
</IconButton>
<button
type="button"

View file

@ -106,6 +106,9 @@ interface FiltersProps {
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
editingSearchName?: string | null;
onUpdateSearch?: () => Promise<void>;
onExitEditing?: () => void;
destinationDropdownPortal?: boolean;
}
@ -148,6 +151,9 @@ export default memo(function Filters({
onClearAll,
onSaveSearch,
savingSearch,
editingSearchName,
onUpdateSearch,
onExitEditing,
destinationDropdownPortal = true,
}: FiltersProps) {
const { t } = useTranslation();
@ -229,7 +235,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
return { ...(backendFeature ?? schoolMeta), name, group: 'Schools' };
});
}, [filters, features, schoolMeta]);
const specificCrimeFilterItems = useMemo(() => {
@ -441,7 +447,7 @@ export default memo(function Filters({
const getAddFilterGroupName = useCallback(
(name: string): string | null => {
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Education';
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
return electionVoteShareMeta.group ?? 'Neighbours';
@ -569,14 +575,14 @@ export default memo(function Filters({
const handleClearAllClick = useCallback(() => {
if (badgeCount === 0) return;
if (onSaveSearch) {
if (onUpdateSearch || onSaveSearch) {
setShowClearPopup(true);
setClearSaveName('');
setClearSaveError(null);
} else {
onClearAll();
}
}, [badgeCount, onSaveSearch, onClearAll]);
}, [badgeCount, onUpdateSearch, onSaveSearch, onClearAll]);
const handleSaveAndClear = useCallback(
async (e: FormEvent) => {
@ -593,10 +599,22 @@ export default memo(function Filters({
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
);
const handleUpdateAndClear = useCallback(async () => {
if (savingSearch || !onUpdateSearch) return;
try {
await onUpdateSearch();
setShowClearPopup(false);
onClearAll();
} catch {
setClearSaveError(t('saveSearch.saving'));
}
}, [savingSearch, onUpdateSearch, onClearAll, t]);
const handleClearWithoutSaving = useCallback(() => {
setShowClearPopup(false);
onClearAll();
}, [onClearAll]);
if (editingSearchName) onExitEditing?.();
}, [onClearAll, editingSearchName, onExitEditing]);
return (
<div
@ -732,9 +750,11 @@ export default memo(function Filters({
saveName={clearSaveName}
saveError={clearSaveError}
savingSearch={savingSearch}
editingSearchName={editingSearchName ?? null}
onClose={() => setShowClearPopup(false)}
onSaveNameChange={setClearSaveName}
onSaveAndClear={handleSaveAndClear}
onUpdateAndClear={onUpdateSearch ? handleUpdateAndClear : undefined}
onClearWithoutSaving={handleClearWithoutSaving}
/>
</div>

View file

@ -65,18 +65,27 @@ describe('JourneyInstructions', () => {
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
});
it('builds explicit Google Maps transit directions instead of a path URL', () => {
it('builds explicit Google Maps transit directions with destination coordinates', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const url = googleMapsUrl('NW7 2GA', 'Bank tube station');
const url = googleMapsUrl('NW7 2GA', 'Bank tube station', 51.5132819, -0.0895555);
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/');
expect(parsed.searchParams.get('api')).toBe('1');
expect(parsed.searchParams.get('origin')).toBe('NW7 2GA');
expect(parsed.searchParams.get('destination')).toBe('Bank Station, London');
expect(parsed.searchParams.get('destination')).toBe('51.5132819,-0.0895555');
expect(parsed.searchParams.get('travelmode')).toBe('transit');
expect(parsed.searchParams.get('departure_time')).toBe('1779085800');
});
it('does not rewrite destination names when coordinates are unavailable', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const parsed = new URL(googleMapsUrl('NW7 2GA', 'Bank tube station'));
expect(parsed.searchParams.get('destination')).toBe('Bank tube station');
});
});

View file

@ -26,6 +26,8 @@ interface JourneyData {
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
destinationLat: number | null;
destinationLon: number | null;
/** Whether the dashboard filter is currently using best-case time. */
useBest: boolean;
loading: boolean;
@ -39,6 +41,8 @@ export interface JourneyInstructionPreset {
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
destinationLat?: number | null;
destinationLon?: number | null;
useBest?: boolean;
}
@ -94,20 +98,33 @@ function nextMondayAt730(): number {
return Math.floor(monday.getTime() / 1000);
}
function googleMapsDestination(destination: string): string {
const clean = stripId(destination).trim();
if (/\btube station$/i.test(clean)) {
return `${clean.replace(/\s+tube station$/i, ' Station')}, London`;
function googleMapsDestination(
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
if (
destinationLat != null &&
destinationLon != null &&
Number.isFinite(destinationLat) &&
Number.isFinite(destinationLon)
) {
return `${destinationLat},${destinationLon}`;
}
return clean;
return stripId(destination).trim();
}
export function googleMapsUrl(origin: string, destination: string): string {
export function googleMapsUrl(
origin: string,
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
const ts = nextMondayAt730();
const params = new URLSearchParams({
api: '1',
origin,
destination: googleMapsDestination(destination),
destination: googleMapsDestination(destination, destinationLat, destinationLon),
travelmode: 'transit',
departure_time: ts.toString(),
});
@ -224,6 +241,8 @@ export default function JourneyInstructions({
legs: null,
minutes: null,
bestMinutes: null,
destinationLat: null,
destinationLon: null,
useBest: e.useBest,
loading: true,
}));
@ -246,17 +265,21 @@ export default function JourneyInstructions({
journey: JourneyLeg[] | null;
minutes: number | null;
best_minutes: number | null;
destination_lat?: number | null;
destination_lon?: number | null;
}) => {
setJourneys((prev) =>
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
destinationLat: data.destination_lat ?? null,
destinationLon: data.destination_lon ?? null,
loading: false,
}
: j
)
);
@ -275,14 +298,16 @@ export default function JourneyInstructions({
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
destinationLat: journey.destinationLat ?? null,
destinationLon: journey.destinationLon ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
: journeys;
return (
@ -326,7 +351,7 @@ export default function JourneyInstructions({
))}
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
@ -361,7 +386,7 @@ export default function JourneyInstructions({
</div>
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"

View file

@ -1,5 +1,5 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
@ -82,6 +82,10 @@ export default function MapPage({
deferTutorial = false,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
onUpdateEditInPlace,
}: MapPageProps) {
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
@ -164,6 +168,7 @@ export default function MapPage({
viewFeature,
activeFeature,
pinnedFeature,
filterRange,
travelTimeEntries: entries,
shareCode,
});
@ -283,7 +288,6 @@ export default function MapPage({
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
@ -506,6 +510,9 @@ export default function MapPage({
},
[dashboardParams, onSaveSearch]
);
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
await onUpdateEditInPlace?.(dashboardParams);
}, [dashboardParams, onUpdateEditInPlace]);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
@ -543,7 +550,6 @@ export default function MapPage({
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
onViewProperties={handleViewPropertiesFromArea}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
@ -621,6 +627,11 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
savingSearch={savingSearch}
editingSearchName={editingSearch?.name ?? null}
onUpdateSearch={
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
}
onExitEditing={onCancelEdit}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
@ -643,6 +654,40 @@ export default function MapPage({
/>
);
const toasts = exportToast;
const editingBar =
editingSearch && isMobile ? (
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
<span
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
title={editingSearch.name}
>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
>
{t('common.cancel')}
</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"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
@ -714,6 +759,7 @@ export default function MapPage({
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
editingBar={editingBar}
/>
);
}

View file

@ -9,6 +9,7 @@ interface VisualViewportState {
interface MobileBottomSheetProps {
children: ReactNode;
legend?: ReactNode;
editingBar?: ReactNode;
onCoveredHeightChange?: (height: number) => void;
}
@ -104,6 +105,7 @@ function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | n
export default function MobileBottomSheet({
children,
legend,
editingBar,
onCoveredHeightChange,
}: MobileBottomSheetProps) {
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
@ -244,6 +246,8 @@ export default function MobileBottomSheet({
</div>
</div>
{editingBar && <div className="shrink-0">{editingBar}</div>}
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}

View file

@ -86,16 +86,17 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-2 md:gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
<InfoIcon className="w-3.5 h-3.5" />
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')} size="md">
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned || isActive}
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
size="md"
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
<EyeIcon className="w-5 h-5 md:w-3.5 md:h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>

View file

@ -110,14 +110,14 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.activeFilters')}
</span>
{badgeCount > 0 && (
<span className="rounded-full bg-teal-50 px-1.5 py-0.5 text-xs font-medium text-teal-700 ring-1 ring-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:ring-teal-800">
<span className="rounded-full bg-teal-600 px-1.5 py-0.5 text-xs font-bold text-white ring-1 ring-teal-700 dark:bg-teal-300 dark:text-navy-950 dark:ring-teal-200">
{badgeCount}
</span>
)}

View file

@ -110,9 +110,9 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
>
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
</span>
<ChevronIcon

View file

@ -1,5 +1,5 @@
import { useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -8,9 +8,11 @@ interface ClearFiltersDialogProps {
saveName: string;
saveError: string | null;
savingSearch?: boolean;
editingSearchName?: string | null;
onClose: () => void;
onSaveNameChange: (value: string) => void;
onSaveAndClear: (e: FormEvent) => void;
onUpdateAndClear?: () => void;
onClearWithoutSaving: () => void;
}
@ -19,12 +21,15 @@ export function ClearFiltersDialog({
saveName,
saveError,
savingSearch,
editingSearchName,
onClose,
onSaveNameChange,
onSaveAndClear,
onUpdateAndClear,
onClearWithoutSaving,
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
useEffect(() => {
if (!open) return;
@ -55,39 +60,74 @@ export function ClearFiltersDialog({
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
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('saveSearch.namePlaceholder')}
autoFocus
/>
{isEditing ? (
<div className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
<Trans
i18nKey="filters.clearAllUpdatePrompt"
values={{ name: editingSearchName }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</p>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutUpdating')}
</button>
<button
type="button"
onClick={onUpdateAndClear}
disabled={savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('filters.updateAndClear')}
</button>
</div>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
) : (
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
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('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
)}
</div>
</div>
);

View file

@ -54,6 +54,7 @@ interface MobileMapPageProps {
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
editingBar?: ReactNode;
}
export function MobileMapPage({
@ -95,6 +96,7 @@ export function MobileMapPage({
renderPropertiesPane,
toasts,
upgradeModal,
editingBar,
}: MobileMapPageProps) {
return (
<div className="flex-1 overflow-hidden relative">
@ -154,6 +156,7 @@ export function MobileMapPage({
<MobileBottomSheet
legend={mobileLegend}
editingBar={editingBar}
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
>
{filtersPane}

View file

@ -47,6 +47,10 @@ export interface MapPageProps {
deferTutorial?: boolean;
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
savingSearch?: boolean;
editingSearch?: { id: string; name: string } | null;
onCancelEdit?: () => void;
onUpdateEdit?: (params: string) => Promise<void>;
onUpdateEditInPlace?: (params: string) => Promise<void>;
}
export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;

View file

@ -212,7 +212,7 @@ export function DestinationDropdown({
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
<div className="relative z-30 mt-1">{dropdown}</div>
))}
</div>
);

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl, prewarmScreenshot, paramsWithLanguage } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
@ -64,6 +64,11 @@ export const PAGE_PATHS: Record<Page, string> = {
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
export interface EditingSearchState {
id: string;
name: string;
}
export default function Header({
activePage,
activeHash,
@ -74,6 +79,9 @@ export default function Header({
dashboardParams,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
user,
onLoginClick,
onRegisterClick,
@ -89,6 +97,9 @@ export default function Header({
dashboardParams: string;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
editingSearch: EditingSearchState | null;
onCancelEdit: () => void;
onUpdateEdit: () => void;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -170,9 +181,38 @@ export default function Header({
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
const showEditingBar = !isMobile && editingSearch && activePage === 'dashboard';
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{showEditingBar && (
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</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"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
</div>
)}
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a
@ -261,7 +301,7 @@ export default function Header({
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && (
{onSaveSearch && !editingSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
@ -369,6 +409,7 @@ export default function Header({
exportState={exportState}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
isEditingSearch={!!editingSearch}
user={user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}

View file

@ -21,6 +21,7 @@ interface MobileMenuProps {
exportState: HeaderExportState | null;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -40,6 +41,7 @@ export default function MobileMenu({
exportState,
onSaveSearch,
savingSearch,
isEditingSearch,
user,
onLoginClick,
onRegisterClick,
@ -144,7 +146,7 @@ export default function MobileMenu({
) : (
<BookmarkIcon className="w-4 h-4" />
)}
{t('common.save')}
{isEditingSearch ? t('common.update') : t('common.save')}
</button>
)}
{dashboardSavedItem}