// Shared API client + state machine for SurveyForge. // Wired against the live API at https://surveyforge-j2jz.onrender.com. const SF_API_BASE = 'https://surveyforge-j2jz.onrender.com'; // — Survey types & templates fetched once, cached on window — const SF_FALLBACK_SURVEY_TYPES = [ { slug: 'reptile', label: 'Reptile (ARG UK AIA)' }, { slug: 'dormouse', label: 'Hazel dormouse' }, { slug: 'badger', label: 'Badger' }, { slug: 'gcn', label: 'Great crested newt' }, { slug: 'otters', label: 'Otter' }, { slug: 'water_voles', label: 'Water vole' }, { slug: 'phase1', label: 'Phase 1 habitat survey' }, { slug: 'arb', label: 'Arboricultural (BS5837)' }, { slug: 'pra', label: 'Preliminary roost assessment (bats)' }, ]; const SF_FALLBACK_TEMPLATES = { default: 'surveyforge_professional', templates: [ { slug: 'surveyforge_professional', name: 'Professional', tagline: 'Clean serif body, conservative spacing — the default' }, { slug: 'surveyforge_classic', name: 'Classic', tagline: 'Times-style headings, formal report register' }, { slug: 'surveyforge_modern', name: 'Modern', tagline: 'Sans-serif throughout, lighter feel' }, { slug: 'surveyforge_consultancy', name: 'Consultancy', tagline: 'Two-column cover, detailed methodology blocks' }, { slug: 'surveyforge_minimal', name: 'Minimal', tagline: 'Stripped back — body text only, no decorative elements' }, ], }; function useSurveyForgeMeta() { const [data, setData] = React.useState({ surveyTypes: null, templates: null, error: null }); React.useEffect(() => { let cancelled = false; async function load() { const out = { surveyTypes: SF_FALLBACK_SURVEY_TYPES, templates: SF_FALLBACK_TEMPLATES, error: null }; try { const [stRes, tRes] = await Promise.all([ fetch(`${SF_API_BASE}/api/survey-types`).then(r => r.ok ? r.json() : null).catch(() => null), fetch(`${SF_API_BASE}/api/templates`).then(r => r.ok ? r.json() : null).catch(() => null), ]); if (stRes && Array.isArray(stRes.survey_types)) out.surveyTypes = stRes.survey_types; if (tRes && Array.isArray(tRes.templates)) out.templates = tRes; } catch (e) { out.error = e.message; } if (!cancelled) setData(out); } load(); return () => { cancelled = true; }; }, []); return data; } // — The form-state machine. Status drives the UI for each direction. — // status: 'idle' | 'ready' | 'generating' | 'success' | 'error' function useSurveyForgeForm({ initialStatus = 'idle' } = {}) { const [state, setState] = React.useState({ surveyType: '', templateSlug: '', customTemplateFile: null, // .docx — overrides templateSlug if set siteName: '', clientName: '', surveyorName: '', projectNumber: '', reportTitle: '', reportStatus: '', // Draft / For Review / Final / etc. reportDate: '', // free text photos: [], // array of { file, caption } file: null, status: initialStatus, progress: 0, error: null, errorKind: null, // 'columns' | 'server' | 'network' | 'validation' downloadUrl: null, downloadName: null, }); const set = React.useCallback((patch) => { setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) })); }, []); const setFile = React.useCallback((f) => { set({ file: f, status: f ? 'ready' : 'idle', error: null, errorKind: null, downloadUrl: null, downloadName: null, }); }, [set]); const reset = React.useCallback(() => { setState((s) => ({ ...s, file: null, status: 'idle', progress: 0, error: null, errorKind: null, downloadUrl: null, downloadName: null, })); }, []); const generate = React.useCallback(async () => { if (!state.file || !state.surveyType) { set({ status: 'error', error: 'Pick a survey type and upload a data file first.', errorKind: 'validation' }); return; } set({ status: 'generating', progress: 0, error: null, errorKind: null, downloadUrl: null }); // Fake-progress while we wait — this is honest because the API takes 2–10s. let progress = 0; const tick = setInterval(() => { progress = Math.min(92, progress + Math.random() * 9 + 2); setState((s) => s.status === 'generating' ? { ...s, progress } : s); }, 380); const fd = new FormData(); fd.append('survey_type', state.surveyType); fd.append('data_file', state.file); // Template: custom file overrides slug if both are set if (state.customTemplateFile) { fd.append('template_file', state.customTemplateFile); } else if (state.templateSlug) { fd.append('template_slug', state.templateSlug); } // Cover-page metadata if (state.siteName) fd.append('site_name', state.siteName); if (state.clientName) fd.append('client_name', state.clientName); if (state.surveyorName) fd.append('surveyor_name', state.surveyorName); if (state.projectNumber) fd.append('project_number', state.projectNumber); if (state.reportTitle) fd.append('report_title', state.reportTitle); if (state.reportStatus) fd.append('status', state.reportStatus); if (state.reportDate) fd.append('report_date', state.reportDate); // Photo attachments — multiple files + parallel JSON captions if (state.photos && state.photos.length) { const captions = []; for (const p of state.photos) { if (!p.file) continue; fd.append('photo_files', p.file); captions.push(p.caption || ''); } fd.append('photo_captions', JSON.stringify(captions)); } try { const res = await fetch(`${SF_API_BASE}/api/generate`, { method: 'POST', body: fd }); clearInterval(tick); if (!res.ok) { let detail = `Request failed (${res.status})`; let kind = res.status >= 500 ? 'server' : 'validation'; try { const j = await res.json(); if (j && j.detail) { detail = j.detail; if (/column|header|recognis|recogniz/i.test(j.detail)) kind = 'columns'; } } catch {} set({ status: 'error', error: detail, errorKind: kind, progress: 0 }); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const cd = res.headers.get('Content-Disposition') || ''; const m = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); const name = (m && decodeURIComponent(m[1])) || `surveyforge_${state.surveyType}.docx`; set({ status: 'success', progress: 100, downloadUrl: url, downloadName: name }); } catch (e) { clearInterval(tick); set({ status: 'error', error: 'Couldn\'t reach the report service. Check your connection and try again.', errorKind: 'network', progress: 0 }); } }, [state, set]); // For the prototype: jump to a state without an actual API call. const simulate = React.useCallback((status) => { if (status === 'idle') { setState((s) => ({ ...s, status: 'idle', file: null, error: null, errorKind: null, downloadUrl: null, progress: 0 })); } else if (status === 'ready') { setState((s) => ({ ...s, status: 'ready', file: s.file || new File(['site,date\nA1,2026-04-01'], 'reptile_standard.csv', { type: 'text/csv' }), error: null, errorKind: null, downloadUrl: null, progress: 0, })); } else if (status === 'generating') { setState((s) => ({ ...s, status: 'generating', progress: 42, error: null, errorKind: null })); } else if (status === 'success') { setState((s) => ({ ...s, status: 'success', progress: 100, downloadUrl: '#', downloadName: `surveyforge_${s.surveyType || 'reptile'}.docx`, error: null, errorKind: null, })); } else if (status === 'error_columns') { setState((s) => ({ ...s, status: 'error', error: 'Data file has unrecognised columns: "transect_id", "weather_code". Expected: site, date, observer, species, count, location.', errorKind: 'columns', progress: 0, })); } else if (status === 'error_server') { setState((s) => ({ ...s, status: 'error', error: 'The report service had a problem on its end. Please try again — if it keeps failing, contact support.', errorKind: 'server', progress: 0, })); } }, []); return { state, set, setFile, reset, generate, simulate }; } // — Tiny shared bits — function fileSize(f) { if (!f) return ''; const n = f.size; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const SF_ACCEPT_FILES = '.csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; Object.assign(window, { SF_API_BASE, SF_FALLBACK_SURVEY_TYPES, SF_FALLBACK_TEMPLATES, SF_ACCEPT_FILES, useSurveyForgeMeta, useSurveyForgeForm, fileSize, });