More FE changes
This commit is contained in:
parent
f114ada255
commit
a48eb945e0
48 changed files with 4127 additions and 1751 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue