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 { 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; 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(null); const exportNoticeTimeoutRef = useRef(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, }; }