208 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|