all good
This commit is contained in:
parent
47d89f6fad
commit
017902b8e6
82 changed files with 331466 additions and 54841 deletions
|
|
@ -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'>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue