More FE changes

This commit is contained in:
Andras Schmelczer 2026-05-09 09:43:41 +01:00
parent f114ada255
commit a48eb945e0
48 changed files with 4127 additions and 1751 deletions

View file

@ -38,10 +38,15 @@ import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
import { useLicense } from '../../hooks/useLicense';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
const Map = lazy(() => import('./Map'));
const Filters = lazy(() => import('./Filters'));
@ -55,7 +60,71 @@ const MapPageSelectionPane = lazy(() =>
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
);
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
const Joyride = lazy(() => import('react-joyride').then((module) => ({ default: module.Joyride })));
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
const EXPORT_NOTICE_MS = 6000;
const EXPORT_ERROR_NOTICE_MS = 9000;
type ExportNotice = {
kind: 'success' | 'error';
message: string;
};
function getExportFileName(res: Response): string {
const disposition = res.headers.get('content-disposition');
if (!disposition) return EXPORT_FILE_NAME;
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const match = disposition.match(/filename="?([^";]+)"?/i);
return match?.[1]?.trim() || EXPORT_FILE_NAME;
}
async function getExportErrorMessage(res: Response): Promise<string> {
const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
const contentType = res.headers.get('content-type') ?? '';
try {
if (contentType.includes('application/json')) {
const data: unknown = await res.json();
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>;
const message = record.message ?? record.error;
if (typeof message === 'string' && message.trim()) return message.trim();
}
return fallback;
}
const text = await res.text();
return text.trim() || fallback;
} catch {
return fallback;
}
}
function triggerExportDownload(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.rel = 'noopener';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
function MapFallback() {
return (
@ -98,8 +167,8 @@ interface MapPageProps {
initialPostcode?: string;
shareCode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
onLoginClick: () => void;
onRegisterClick: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
@ -146,11 +215,14 @@ export default function MapPage({
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
const exportNoticeTimeoutRef = useRef<number | null>(null);
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
@ -166,6 +238,35 @@ export default function MapPage({
const { t } = useTranslation();
const modes = useTranslatedModes();
const clearExportNoticeTimer = useCallback(() => {
if (exportNoticeTimeoutRef.current !== null) {
window.clearTimeout(exportNoticeTimeoutRef.current);
exportNoticeTimeoutRef.current = null;
}
}, []);
const clearExportNotice = useCallback(() => {
clearExportNoticeTimer();
setExportNotice(null);
}, [clearExportNoticeTimer]);
const showExportNotice = useCallback(
(notice: ExportNotice) => {
clearExportNoticeTimer();
setExportNotice(notice);
exportNoticeTimeoutRef.current = window.setTimeout(
() => {
setExportNotice(null);
exportNoticeTimeoutRef.current = null;
},
notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS
);
},
[clearExportNoticeTimer]
);
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
const {
filters,
activeFeature,
@ -555,10 +656,16 @@ export default function MapPage({
]);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return;
if (exporting) return;
if (!mapData.bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const { south, west, north, east } = mapData.bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
@ -567,23 +674,48 @@ export default function MapPage({
if (filterStr) params.set('filters', filterStr);
const url = apiUrl('export', params);
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true);
fetch(url, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
clearExportNotice();
void (async () => {
try {
const res = await fetch(url, authHeaders({ signal: controller.signal }));
if (!res.ok) throw new Error(await getExportErrorMessage(res));
const blob = await res.blob();
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export');
})
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
showExportNotice({ kind: 'success', message: t('header.exportReady') });
} catch (err) {
if (!timedOut) logNonAbortError('Export failed', err);
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
}
})();
}, [
clearExportNotice,
exporting,
features,
filters,
mapData.bounds,
showExportNotice,
t,
]);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
@ -600,6 +732,8 @@ export default function MapPage({
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
getEthnicityFeatureName(viewFeature) ??
getPoiDistanceFeatureName(viewFeature) ??
viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
@ -609,6 +743,8 @@ export default function MapPage({
viewFeature
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
getEthnicityFeatureName(viewFeature) ??
getPoiDistanceFeatureName(viewFeature) ??
viewFeature)
: null,
[viewFeature]
@ -685,6 +821,28 @@ export default function MapPage({
</div>
);
const exportToast = exportNotice && (
<div
role={exportNotice.kind === 'error' ? 'alert' : 'status'}
aria-live={exportNotice.kind === 'error' ? 'assertive' : 'polite'}
className={`fixed ${showBookmarkToast ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
>
{exportNotice.kind === 'success' ? (
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
) : (
<InfoIcon className="h-4 w-4 shrink-0 text-red-300" />
)}
<span className="min-w-0">{exportNotice.message}</span>
<button
onClick={clearExportNotice}
aria-label={t('common.close')}
className="-mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded text-warm-300 hover:bg-navy-800 hover:text-white"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
);
if (screenshotMode) {
return (
<div className="h-full w-full">
@ -805,7 +963,7 @@ export default function MapPage({
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
onLoginRequired={onRegisterClick}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
@ -859,6 +1017,7 @@ export default function MapPage({
featureName={mobileLegendMeta.name}
theme={theme}
inline
suffix={mobileLegendMeta.suffix}
raw={mobileLegendMeta.raw}
/>
);
@ -926,21 +1085,11 @@ export default function MapPage({
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagon}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
/>
</Suspense>
</div>
{mapData.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
@ -955,7 +1104,10 @@ export default function MapPage({
</div>
)}
<MobileBottomSheet legend={renderMobileLegend()}>
<MobileBottomSheet
legend={renderMobileLegend()}
onCoveredHeightChange={setMobileBottomSheetHeight}
>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
@ -979,13 +1131,14 @@ export default function MapPage({
)}
{bookmarkToast}
{exportToast}
{mapData.licenseRequired && (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
@ -1015,11 +1168,14 @@ export default function MapPage({
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
onEvent={tutorial.handleCallback}
styles={tutorialTheme.styles}
options={{
...tutorialTheme.options,
buttons: ['back', 'close', 'primary', 'skip'],
showProgress: true,
skipScroll: true,
}}
locale={{ last: 'Finish' }}
/>
</Suspense>
@ -1044,6 +1200,15 @@ export default function MapPage({
</div>
<div data-tutorial="map" className="flex-1 relative">
{tutorial.run && (
<>
<div
data-tutorial="map-anchor"
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
/>
</>
)}
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
@ -1077,16 +1242,6 @@ export default function MapPage({
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
</Suspense>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
{/* Floating POI button */}
<button
data-tutorial="poi-button"
@ -1120,13 +1275,14 @@ export default function MapPage({
)}
{bookmarkToast}
{exportToast}
{mapData.licenseRequired && (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}