all good
This commit is contained in:
parent
47d89f6fad
commit
017902b8e6
82 changed files with 331466 additions and 54841 deletions
92
frontend/package-lock.json
generated
92
frontend/package-lock.json
generated
|
|
@ -20,6 +20,7 @@
|
|||
"@protomaps/basemaps": "^5.7.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"i18next": "^26.0.10",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
|
|
@ -5287,6 +5288,97 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
|
||||
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
|
||||
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
|
||||
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "10.53.1",
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
|
||||
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "10.53.1",
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
|
||||
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "10.53.1",
|
||||
"@sentry-internal/feedback": "10.53.1",
|
||||
"@sentry-internal/replay": "10.53.1",
|
||||
"@sentry-internal/replay-canvas": "10.53.1",
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
|
||||
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "10.53.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
|
||||
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.53.1",
|
||||
"@sentry/core": "10.53.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@protomaps/basemaps": "^5.7.2",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"i18next": "^26.0.10",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export default function App() {
|
|||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [editingSearch, setEditingSearch] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -374,11 +375,52 @@ export default function App() {
|
|||
}
|
||||
setRouteHash(targetHash);
|
||||
setActivePage(page);
|
||||
setEditingSearch(null);
|
||||
if (targetHash) scrollToHash(targetHash);
|
||||
},
|
||||
[inviteCode]
|
||||
);
|
||||
|
||||
const handleEditSearch = useCallback(
|
||||
(id: string, name: string, params: string) => {
|
||||
const search = params.startsWith('?') ? params : `?${params}`;
|
||||
dashboardSearchRef.current = search;
|
||||
const url = `/dashboard${search}`;
|
||||
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(search);
|
||||
setRouteHash('');
|
||||
setActivePage('dashboard');
|
||||
setEditingSearch({ id, name });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingSearch(null);
|
||||
}, []);
|
||||
|
||||
const updateEditingSearch = useCallback(
|
||||
async (params: string) => {
|
||||
if (!editingSearch) return;
|
||||
await savedSearches.updateSearchParams(editingSearch.id, params);
|
||||
setEditingSearch(null);
|
||||
},
|
||||
[editingSearch, savedSearches]
|
||||
);
|
||||
|
||||
const handleUpdateEdit = useCallback(
|
||||
async (params: string) => {
|
||||
try {
|
||||
await updateEditingSearch(params);
|
||||
navigateTo('saved');
|
||||
} catch {
|
||||
// Error stored on savedSearches.error
|
||||
}
|
||||
},
|
||||
[updateEditingSearch, navigateTo]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || postAuthIntent !== 'checkout') return;
|
||||
|
||||
|
|
@ -439,6 +481,8 @@ export default function App() {
|
|||
if (page === 'dashboard') {
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(window.location.search);
|
||||
} else {
|
||||
setEditingSearch(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
|
@ -517,8 +561,17 @@ export default function App() {
|
|||
onToggleTheme={toggleTheme}
|
||||
exportState={activePage === 'dashboard' ? exportState : null}
|
||||
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||
onSaveSearch={
|
||||
activePage === 'dashboard' && user
|
||||
? editingSearch
|
||||
? () => handleUpdateEdit(dashboardParams)
|
||||
: () => setShowSaveModal(true)
|
||||
: null
|
||||
}
|
||||
savingSearch={savedSearches.saving}
|
||||
editingSearch={activePage === 'dashboard' ? editingSearch : null}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onUpdateEdit={() => handleUpdateEdit(dashboardParams)}
|
||||
user={user}
|
||||
onLoginClick={() => openAuthModal('login')}
|
||||
onRegisterClick={() => openAuthModal('register')}
|
||||
|
|
@ -553,9 +606,7 @@ export default function App() {
|
|||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
||||
onUpdateSearchName={savedSearches.updateSearchName}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
onOpenSearch={handleEditSearch}
|
||||
/>
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage
|
||||
|
|
@ -609,6 +660,10 @@ export default function App() {
|
|||
deferTutorial={licenseSuccessStatus !== 'hidden'}
|
||||
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
||||
savingSearch={savedSearches.saving}
|
||||
editingSearch={editingSearch}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onUpdateEdit={handleUpdateEdit}
|
||||
onUpdateEditInPlace={updateEditingSearch}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ describe('useMapData', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('resets the colour range to drag preview data while a slider is active', async () => {
|
||||
it('resets the colour range to visible drag preview data while a slider is active', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
|
|
@ -139,16 +139,28 @@ describe('useMapData', () => {
|
|||
const filters = { price: [20, 80] as [number, number] };
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeFeature }: { activeFeature: string | null }) =>
|
||||
({
|
||||
activeFeature,
|
||||
filterRange,
|
||||
}: {
|
||||
activeFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
}) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: null,
|
||||
filterRange,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{ initialProps: { activeFeature: null as string | null } }
|
||||
{
|
||||
initialProps: {
|
||||
activeFeature: null as string | null,
|
||||
filterRange: filters.price,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -171,27 +183,58 @@ describe('useMapData', () => {
|
|||
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
|
||||
|
||||
await act(async () => {
|
||||
rerender({ activeFeature: 'price' });
|
||||
rerender({ activeFeature: 'price', filterRange: filters.price });
|
||||
await flushPromises();
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
const previewData = [
|
||||
{
|
||||
h3: 'preview-outside-low',
|
||||
count: 1,
|
||||
lat: 1.1,
|
||||
lon: 1.1,
|
||||
min_price: 0,
|
||||
max_price: 10,
|
||||
avg_price: 5,
|
||||
},
|
||||
{
|
||||
h3: 'preview-low',
|
||||
count: 1,
|
||||
lat: 1.25,
|
||||
lon: 1.25,
|
||||
min_price: 20,
|
||||
max_price: 20,
|
||||
avg_price: 20,
|
||||
},
|
||||
{
|
||||
h3: 'preview-high',
|
||||
count: 1,
|
||||
lat: 1.75,
|
||||
lon: 1.75,
|
||||
min_price: 80,
|
||||
max_price: 80,
|
||||
avg_price: 80,
|
||||
},
|
||||
{
|
||||
h3: 'preview-outside-high',
|
||||
count: 1,
|
||||
lat: 1.9,
|
||||
lon: 1.9,
|
||||
min_price: 90,
|
||||
max_price: 100,
|
||||
avg_price: 95,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
requests[1].resolve(
|
||||
response([
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
])
|
||||
);
|
||||
requests[1].resolve(response(previewData));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
]);
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||
expect(result.current.data).toEqual(previewData);
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
|
||||
});
|
||||
|
||||
it('does not use metadata min/max while slider preview colour data is loading', async () => {
|
||||
|
|
@ -270,6 +313,82 @@ describe('useMapData', () => {
|
|||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||
});
|
||||
|
||||
it('does not use stale committed feature data while slider preview colour data is loading', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 1_000,
|
||||
},
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
filters,
|
||||
activeFeature,
|
||||
}: {
|
||||
filters: Record<string, [number, number]>;
|
||||
activeFeature: string | null;
|
||||
}) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
filters: { price: [0, 1_000] as [number, number] },
|
||||
activeFeature: null as string | null,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(viewChange(bounds));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
await act(async () => {
|
||||
requests[0].resolve(
|
||||
response([
|
||||
{ h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(950);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [20, 80] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.colorRange).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
requests[1].resolve(
|
||||
response([
|
||||
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
|
||||
});
|
||||
|
||||
it('does not reuse cached drag preview data when the drag request changes', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
|
|
|
|||
|
|
@ -45,18 +45,38 @@ interface UseMapDataOptions {
|
|||
viewFeature: string | null;
|
||||
activeFeature: string | null;
|
||||
pinnedFeature: string | null;
|
||||
filterRange?: [number, number] | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
/** Share-link code from the URL; appended to data fetches so the backend
|
||||
* grants bbox-scoped access for unlicensed recipients. */
|
||||
shareCode?: string;
|
||||
}
|
||||
|
||||
function getFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function valueInVisibleRange(
|
||||
value: number,
|
||||
minValue: number | null,
|
||||
maxValue: number | null,
|
||||
visibleRange: [number, number] | null
|
||||
): number | null {
|
||||
if (!visibleRange) return value;
|
||||
|
||||
const itemMin = minValue ?? value;
|
||||
const itemMax = maxValue ?? value;
|
||||
if (itemMax < visibleRange[0] || itemMin > visibleRange[1]) return null;
|
||||
return Math.max(visibleRange[0], Math.min(visibleRange[1], value));
|
||||
}
|
||||
|
||||
export function useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
pinnedFeature,
|
||||
filterRange = null,
|
||||
travelTimeEntries,
|
||||
shareCode,
|
||||
}: UseMapDataOptions) {
|
||||
|
|
@ -487,8 +507,15 @@ export function useMapData({
|
|||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||
continue;
|
||||
}
|
||||
const val = feat.properties[`avg_${dataViewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
const val = getFiniteNumber(feat.properties[`avg_${dataViewFeature}`]);
|
||||
if (val == null) continue;
|
||||
const visibleValue = valueInVisibleRange(
|
||||
val,
|
||||
getFiniteNumber(feat.properties[`min_${dataViewFeature}`]),
|
||||
getFiniteNumber(feat.properties[`max_${dataViewFeature}`]),
|
||||
filterRange
|
||||
);
|
||||
if (visibleValue != null) vals.push(visibleValue);
|
||||
}
|
||||
} else {
|
||||
if (data.length === 0) return null;
|
||||
|
|
@ -498,8 +525,15 @@ export function useMapData({
|
|||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||
continue;
|
||||
}
|
||||
const val = item[`avg_${dataViewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
const val = getFiniteNumber(item[`avg_${dataViewFeature}`]);
|
||||
if (val == null) continue;
|
||||
const visibleValue = valueInVisibleRange(
|
||||
val,
|
||||
getFiniteNumber(item[`min_${dataViewFeature}`]),
|
||||
getFiniteNumber(item[`max_${dataViewFeature}`]),
|
||||
filterRange
|
||||
);
|
||||
if (visibleValue != null) vals.push(visibleValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +549,7 @@ export function useMapData({
|
|||
dataViewFeature,
|
||||
effectivePostcodeData,
|
||||
features,
|
||||
filterRange,
|
||||
hasCurrentRangeData,
|
||||
usePostcodeView,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -176,6 +176,46 @@ export function useSavedSearches(userId: string | null) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const updateSearchParams = useCallback(
|
||||
async (id: string, params: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const record = await pb.collection('saved_searches').update(id, { params });
|
||||
trackEvent('Search Update');
|
||||
setSearches((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s))
|
||||
);
|
||||
|
||||
// Refresh screenshot in the background
|
||||
const screenshotParams = new URLSearchParams(params);
|
||||
const screenshotUrl = apiUrl('screenshot', screenshotParams);
|
||||
fetch(screenshotUrl, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const patch = new FormData();
|
||||
patch.append('screenshot', blob, 'screenshot.jpg');
|
||||
return pb.collection('saved_searches').update(record.id, patch);
|
||||
})
|
||||
.then(() => fetchSearches())
|
||||
.catch((err) => {
|
||||
console.warn('Background screenshot failed:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update search';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[userId, fetchSearches]
|
||||
);
|
||||
|
||||
return {
|
||||
searches,
|
||||
loading,
|
||||
|
|
@ -186,5 +226,6 @@ export function useSavedSearches(userId: string | null) {
|
|||
deleteSearch,
|
||||
updateSearchNotes,
|
||||
updateSearchName,
|
||||
updateSearchParams,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const de: Translations = {
|
|||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
update: 'Aktualisieren',
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
delete: 'Löschen',
|
||||
|
|
@ -677,8 +678,12 @@ const de: Translations = {
|
|||
clearAll: 'Alle löschen',
|
||||
clearAllTitle: 'Alle Filter löschen?',
|
||||
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
|
||||
clearAllUpdatePrompt:
|
||||
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
|
||||
saveAndClear: 'Speichern & löschen',
|
||||
updateAndClear: 'Aktualisieren & löschen',
|
||||
clearWithoutSaving: 'Ohne Speichern löschen',
|
||||
clearWithoutUpdating: 'Ohne Aktualisieren löschen',
|
||||
filtersOut: 'filtert {{value}} heraus',
|
||||
schoolType: 'Schultyp',
|
||||
schoolRating: 'Schulbewertung',
|
||||
|
|
@ -1280,6 +1285,8 @@ const de: Translations = {
|
|||
deleteSearch: 'Suche löschen',
|
||||
deleteSearchConfirm:
|
||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
||||
updating: 'Aktualisiere...',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1378,6 +1385,7 @@ const de: Translations = {
|
|||
'Property prices': 'Immobilienpreise',
|
||||
Transport: 'Verkehr',
|
||||
Education: 'Bildung',
|
||||
'Defining characteristics': 'Prägende Merkmale',
|
||||
'Area development': 'Gebietsentwicklung',
|
||||
Crime: 'Kriminalität',
|
||||
Neighbours: 'Nachbarn',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const en = {
|
|||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Save',
|
||||
update: 'Update',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
|
|
@ -652,8 +653,11 @@ const en = {
|
|||
clearAll: 'Clear all',
|
||||
clearAllTitle: 'Clear all filters?',
|
||||
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
|
||||
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
|
||||
saveAndClear: 'Save & Clear',
|
||||
updateAndClear: 'Update & Clear',
|
||||
clearWithoutSaving: 'Clear without saving',
|
||||
clearWithoutUpdating: 'Clear without updating',
|
||||
filtersOut: 'filters out {{value}}',
|
||||
schoolType: 'School type',
|
||||
schoolRating: 'School rating',
|
||||
|
|
@ -1245,6 +1249,8 @@ const en = {
|
|||
notesPlaceholder: 'Jot down your thoughts...',
|
||||
deleteSearch: 'Delete search',
|
||||
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This can’t be undone.',
|
||||
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
|
||||
updating: 'Updating...',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1342,6 +1348,7 @@ const en = {
|
|||
'Property prices': 'Property prices',
|
||||
Transport: 'Transport',
|
||||
Education: 'Education',
|
||||
'Defining characteristics': 'Defining characteristics',
|
||||
'Area development': 'Area development',
|
||||
Crime: 'Crime',
|
||||
Neighbours: 'Neighbours',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const fr: Translations = {
|
|||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Enregistrer',
|
||||
update: 'Mettre à jour',
|
||||
cancel: 'Annuler',
|
||||
close: 'Fermer',
|
||||
delete: 'Supprimer',
|
||||
|
|
@ -681,8 +682,12 @@ const fr: Translations = {
|
|||
clearAll: 'Tout effacer',
|
||||
clearAllTitle: 'Effacer tous les filtres ?',
|
||||
clearAllSavePrompt: 'Souhaitez-vous sauvegarder vos filtres actuels avant de les effacer ?',
|
||||
clearAllUpdatePrompt:
|
||||
'Mettre à jour <strong>{{name}}</strong> avec vos filtres actuels avant d’effacer ?',
|
||||
saveAndClear: 'Sauvegarder et effacer',
|
||||
updateAndClear: 'Mettre à jour et effacer',
|
||||
clearWithoutSaving: 'Effacer sans sauvegarder',
|
||||
clearWithoutUpdating: 'Effacer sans mettre à jour',
|
||||
filtersOut: 'exclut {{value}}',
|
||||
schoolType: 'Type d’école',
|
||||
schoolRating: 'Note de l’école',
|
||||
|
|
@ -1286,6 +1291,8 @@ const fr: Translations = {
|
|||
deleteSearch: 'Supprimer la recherche',
|
||||
deleteSearchConfirm:
|
||||
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
|
||||
isBeingUpdated: 'Mise à jour de <strong>{{name}}</strong>',
|
||||
updating: 'Mise à jour...',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1384,6 +1391,7 @@ const fr: Translations = {
|
|||
'Property prices': 'Prix immobiliers',
|
||||
Transport: 'Transports',
|
||||
Education: 'Éducation',
|
||||
'Defining characteristics': 'Caractéristiques déterminantes',
|
||||
'Area development': 'Développement du quartier',
|
||||
Crime: 'Criminalité',
|
||||
Neighbours: 'Voisins',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Translations } from './en';
|
|||
const hi: Translations = {
|
||||
common: {
|
||||
save: 'सहेजें',
|
||||
update: 'अपडेट करें',
|
||||
cancel: 'रद्द करें',
|
||||
close: 'बंद करें',
|
||||
delete: 'हटाएं',
|
||||
|
|
@ -649,8 +650,12 @@ const hi: Translations = {
|
|||
clearAll: 'सभी साफ करें',
|
||||
clearAllTitle: 'सभी फिल्टर साफ करें?',
|
||||
clearAllSavePrompt: 'क्या साफ करने से पहले आप अपने मौजूदा फिल्टर सहेजना चाहेंगे?',
|
||||
clearAllUpdatePrompt:
|
||||
'साफ करने से पहले <strong>{{name}}</strong> को अपने मौजूदा फिल्टर के साथ अपडेट करें?',
|
||||
saveAndClear: 'सहेजें और साफ करें',
|
||||
updateAndClear: 'अपडेट करें और साफ करें',
|
||||
clearWithoutSaving: 'बिना सहेजे साफ करें',
|
||||
clearWithoutUpdating: 'बिना अपडेट किए साफ करें',
|
||||
filtersOut: '{{value}} को फिल्टर करता है',
|
||||
schoolType: 'स्कूल प्रकार',
|
||||
schoolRating: 'स्कूल रेटिंग',
|
||||
|
|
@ -1210,6 +1215,8 @@ const hi: Translations = {
|
|||
deleteSearch: 'खोज हटाएं',
|
||||
deleteSearchConfirm:
|
||||
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
|
||||
isBeingUpdated: '<strong>{{name}}</strong> अपडेट हो रहा है',
|
||||
updating: 'अपडेट हो रहा है...',
|
||||
},
|
||||
|
||||
invitesPage: {
|
||||
|
|
@ -1299,6 +1306,7 @@ const hi: Translations = {
|
|||
'Property prices': 'संपत्ति कीमतें',
|
||||
Transport: 'परिवहन',
|
||||
Education: 'शिक्षा',
|
||||
'Defining characteristics': 'मुख्य विशेषताएं',
|
||||
'Area development': 'क्षेत्र विकास',
|
||||
Crime: 'अपराध',
|
||||
Neighbours: 'पड़ोसी',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const hu: Translations = {
|
|||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Mentés',
|
||||
update: 'Frissítés',
|
||||
cancel: 'Mégse',
|
||||
close: 'Bezárás',
|
||||
delete: 'Törlés',
|
||||
|
|
@ -665,8 +666,12 @@ const hu: Translations = {
|
|||
clearAll: 'Összes törlése',
|
||||
clearAllTitle: 'Összes szűrő törlése?',
|
||||
clearAllSavePrompt: 'Szeretnéd menteni a jelenlegi szűrőket a törlés előtt?',
|
||||
clearAllUpdatePrompt:
|
||||
'Frissíted a(z) <strong>{{name}}</strong> keresést a jelenlegi szűrőkkel törlés előtt?',
|
||||
saveAndClear: 'Mentés és törlés',
|
||||
updateAndClear: 'Frissítés és törlés',
|
||||
clearWithoutSaving: 'Törlés mentés nélkül',
|
||||
clearWithoutUpdating: 'Törlés frissítés nélkül',
|
||||
filtersOut: '{{value}} elemet kiszűr',
|
||||
schoolType: 'Iskolatípus',
|
||||
schoolRating: 'Iskolai értékelés',
|
||||
|
|
@ -1264,6 +1269,8 @@ const hu: Translations = {
|
|||
deleteSearch: 'Keresés törlése',
|
||||
deleteSearchConfirm:
|
||||
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
|
||||
isBeingUpdated: '<strong>{{name}}</strong> frissítése folyamatban',
|
||||
updating: 'Frissítés...',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1363,6 +1370,7 @@ const hu: Translations = {
|
|||
'Property prices': 'Ingatlanárak',
|
||||
Transport: 'Közlekedés',
|
||||
Education: 'Oktatás',
|
||||
'Defining characteristics': 'Meghatározó jellemzők',
|
||||
'Area development': 'Területi fejlődés',
|
||||
Crime: 'Bűnözés',
|
||||
Neighbours: 'Szomszédok',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const zh: Translations = {
|
|||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: '保存',
|
||||
update: '更新',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
delete: '删除',
|
||||
|
|
@ -616,8 +617,11 @@ const zh: Translations = {
|
|||
clearAll: '全部清除',
|
||||
clearAllTitle: '清除所有筛选条件?',
|
||||
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
|
||||
clearAllUpdatePrompt: '在清除前使用当前筛选条件更新 <strong>{{name}}</strong>?',
|
||||
saveAndClear: '保存并清除',
|
||||
updateAndClear: '更新并清除',
|
||||
clearWithoutSaving: '不保存直接清除',
|
||||
clearWithoutUpdating: '不更新直接清除',
|
||||
filtersOut: '筛除 {{value}}',
|
||||
schoolType: '学校类型',
|
||||
schoolRating: '学校评级',
|
||||
|
|
@ -1195,6 +1199,8 @@ const zh: Translations = {
|
|||
notesPlaceholder: '记下您的想法...',
|
||||
deleteSearch: '删除搜索',
|
||||
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
|
||||
isBeingUpdated: '正在更新 <strong>{{name}}</strong>',
|
||||
updating: '更新中...',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
|
|
@ -1290,6 +1296,7 @@ const zh: Translations = {
|
|||
'Property prices': '房价',
|
||||
Transport: '交通',
|
||||
Education: '教育',
|
||||
'Defining characteristics': '主要特征',
|
||||
'Area development': '区域发展',
|
||||
Crime: '犯罪',
|
||||
Neighbours: '邻居',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<title>Perfect Postcode - Find where to buy before browsing listings</title>
|
||||
<meta name="description" content="Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings." />
|
||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme');
|
||||
|
|
|
|||
|
|
@ -1,15 +1,29 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { i18nReady } from './i18n';
|
||||
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
|
||||
import './index.css';
|
||||
import './hooks/usePlausible';
|
||||
|
||||
initBugsink();
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
const root = container;
|
||||
|
||||
function AppErrorFallback() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
const hasPrerenderedMarkup = root.children.length > 0;
|
||||
|
||||
|
|
@ -18,7 +32,11 @@ function renderApp() {
|
|||
}
|
||||
root.removeAttribute('data-prerender-path');
|
||||
|
||||
createRoot(root).render(<App />);
|
||||
createRoot(root).render(
|
||||
<BugsinkErrorBoundary fallback={<AppErrorFallback />}>
|
||||
<App />
|
||||
</BugsinkErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
void i18nReady.then(renderApp);
|
||||
|
|
|
|||
100
frontend/src/lib/bugsink.tsx
Normal file
100
frontend/src/lib/bugsink.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as Sentry from '@sentry/react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
declare const __BUGSINK_DSN__: string | undefined;
|
||||
declare const __BUGSINK_ENVIRONMENT__: string | undefined;
|
||||
declare const __BUGSINK_RELEASE__: string | undefined;
|
||||
declare const __BUGSINK_SEND_DEFAULT_PII__: boolean | undefined;
|
||||
|
||||
interface BugsinkConfig {
|
||||
dsn?: string;
|
||||
environment?: string;
|
||||
release?: string;
|
||||
sendDefaultPii?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PERFECT_POSTCODE_BUGSINK__?: BugsinkConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function nonempty(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readBuildTimeString(value: unknown): string | undefined {
|
||||
return nonempty(value);
|
||||
}
|
||||
|
||||
function readBuildTimeBoolean(value: unknown): boolean {
|
||||
return typeof value === 'boolean' ? value : false;
|
||||
}
|
||||
|
||||
function readRuntimeConfig(): BugsinkConfig {
|
||||
if (typeof document === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const element = document.getElementById('perfect-postcode-bugsink-config');
|
||||
const json = element?.textContent?.trim();
|
||||
if (!json || json === '__PERFECT_POSTCODE_BUGSINK_CONFIG__') {
|
||||
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(json) as BugsinkConfig;
|
||||
window.__PERFECT_POSTCODE_BUGSINK__ = config;
|
||||
return config;
|
||||
} catch {
|
||||
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
export function initBugsink(): boolean {
|
||||
const runtimeConfig = readRuntimeConfig();
|
||||
const dsn =
|
||||
nonempty(runtimeConfig.dsn) ??
|
||||
readBuildTimeString(typeof __BUGSINK_DSN__ === 'string' ? __BUGSINK_DSN__ : undefined);
|
||||
|
||||
if (!dsn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment:
|
||||
nonempty(runtimeConfig.environment) ??
|
||||
readBuildTimeString(
|
||||
typeof __BUGSINK_ENVIRONMENT__ === 'string' ? __BUGSINK_ENVIRONMENT__ : undefined
|
||||
),
|
||||
release:
|
||||
nonempty(runtimeConfig.release) ??
|
||||
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
|
||||
sendDefaultPii:
|
||||
runtimeConfig.sendDefaultPii ??
|
||||
readBuildTimeBoolean(
|
||||
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
|
||||
? __BUGSINK_SEND_DEFAULT_PII__
|
||||
: undefined
|
||||
),
|
||||
tracesSampleRate: 0,
|
||||
});
|
||||
Sentry.setTag('app', 'frontend');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function BugsinkErrorBoundary({
|
||||
children,
|
||||
fallback,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fallback: ReactElement;
|
||||
}) {
|
||||
return <Sentry.ErrorBoundary fallback={fallback}>{children}</Sentry.ErrorBoundary>;
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
|||
'Property prices': TagIcon,
|
||||
Transport: RouteIcon,
|
||||
Education: GraduationCapIcon,
|
||||
Schools: GraduationCapIcon,
|
||||
'Defining characteristics': TreeIcon,
|
||||
'Area development': ChartBarIcon,
|
||||
Crime: ShieldIcon,
|
||||
Neighbours: UsersIcon,
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
|||
return {
|
||||
name: SCHOOL_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Education',
|
||||
group: 'Schools',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 10,
|
||||
step: 1,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,36 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
|
|||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
||||
const sharp = require('sharp');
|
||||
const webpack = require('webpack');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
const HOUSE_IMAGE_WIDTH = 260;
|
||||
|
||||
function envString(...names) {
|
||||
for (const name of names) {
|
||||
const value = process.env[name];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envBoolean(name, fallback = false) {
|
||||
const value = process.env[name];
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProduction = argv.mode === 'production';
|
||||
const bugsinkEnvironment =
|
||||
envString('FRONTEND_BUGSINK_ENVIRONMENT', 'BUGSINK_ENVIRONMENT', 'SENTRY_ENVIRONMENT') ||
|
||||
(isProduction ? 'production' : 'development');
|
||||
const bugsinkRelease =
|
||||
envString('FRONTEND_BUGSINK_RELEASE', 'BUGSINK_RELEASE', 'SENTRY_RELEASE') ||
|
||||
`${packageJson.name}@${packageJson.version}`;
|
||||
|
||||
return {
|
||||
entry: './src/index.tsx',
|
||||
|
|
@ -22,6 +47,7 @@ module.exports = (env, argv) => {
|
|||
|
||||
publicPath: '/',
|
||||
},
|
||||
devtool: isProduction ? 'hidden-source-map' : 'eval-cheap-module-source-map',
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
},
|
||||
|
|
@ -62,6 +88,14 @@ module.exports = (env, argv) => {
|
|||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
__DEV__: JSON.stringify(!isProduction),
|
||||
__BUGSINK_DSN__: JSON.stringify(
|
||||
envString('FRONTEND_BUGSINK_DSN', 'PUBLIC_BUGSINK_DSN', 'BUGSINK_DSN') || ''
|
||||
),
|
||||
__BUGSINK_ENVIRONMENT__: JSON.stringify(bugsinkEnvironment),
|
||||
__BUGSINK_RELEASE__: JSON.stringify(bugsinkRelease),
|
||||
__BUGSINK_SEND_DEFAULT_PII__: JSON.stringify(
|
||||
envBoolean('BUGSINK_SEND_DEFAULT_PII', false)
|
||||
),
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/index.html',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue