perfect-postcode/frontend/src/components/map/map-page/useExportController.ts
2026-05-14 20:42:48 +01:00

208 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { buildTravelParam } from '../../../lib/travel-params';
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;
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 appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
for (const entry of entries) {
if (!entry.slug) continue;
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';
if (entry.timeRange) {
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', value);
}
}
interface UseExportControllerOptions {
bounds: Bounds | null;
filters: FeatureFilters;
features: FeatureMeta[];
travelTimeEntries: TravelTimeEntry[];
shareCode?: string;
t: TFunction;
onExportStateChange?: (state: ExportState) => void;
}
export function useExportController({
bounds,
filters,
features,
travelTimeEntries,
shareCode,
t,
onExportStateChange,
}: UseExportControllerOptions) {
const [exporting, setExporting] = useState(false);
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
const exportNoticeTimeoutRef = useRef<number | null>(null);
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 handleExport = useCallback(() => {
if (exporting) return;
if (!bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const { south, west, north, east } = bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
if (shareCode) params.set('share', shareCode);
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);
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');
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);
}
})();
}, [
bounds,
clearExportNotice,
exporting,
features,
filters,
shareCode,
showExportNotice,
t,
travelTimeEntries,
]);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
return {
exporting,
exportNotice,
clearExportNotice,
handleExport,
};
}