// Proposal Builder — context form + multi-technique assembled, printable proposal document const PROPOSAL_CTX_KEY = 'proposalContext_v1'; const PROPOSAL_CART_KEY = 'proposalCart_v1'; function loadCtx() { try { return JSON.parse(localStorage.getItem(PROPOSAL_CTX_KEY)) || {}; } catch { return {}; } } function loadCart() { try { return JSON.parse(localStorage.getItem(PROPOSAL_CART_KEY)) || []; } catch { return []; } } const CTX_DEFAULTS = { clientName: '', preparedBy: '', productName: '', indication: '', audience: '', sampleSize: '', currency: '£', objectives: '', }; // Hook to manage proposal cart + context globally function useProposal() { const [cart, setCart] = React.useState(loadCart); const [ctx, setCtx] = React.useState(() => ({ ...CTX_DEFAULTS, ...loadCtx() })); React.useEffect(() => { localStorage.setItem(PROPOSAL_CART_KEY, JSON.stringify(cart)); }, [cart]); React.useEffect(() => { localStorage.setItem(PROPOSAL_CTX_KEY, JSON.stringify(ctx)); }, [ctx]); const inCart = id => cart.includes(id); const toggleCart = id => setCart(c => c.includes(id) ? c.filter(x => x !== id) : [...c, id]); const removeFromCart = id => setCart(c => c.filter(x => x !== id)); const clearCart = () => setCart([]); const setCtxField = (k, v) => setCtx(c => ({ ...c, [k]: v })); return { cart, inCart, toggleCart, removeFromCart, clearCart, ctx, setCtx, setCtxField }; } // Compute combined timeline (phases run partly sequentially; we estimate calendar weeks // as the longest single study plus a fraction of the others when run in parallel) function combinedTimeline(techniques) { if (!techniques.length) return { total: 0, perStudy: [] }; const totals = techniques.map(t => { const ex = window.PROPOSAL_EXTRAS[t.id]; const wk = ex ? ex.timeline.reduce((s, p) => s + p.weeks, 0) : 6; return { id: t.id, name: t.name, weeks: wk }; }); const longest = Math.max(...totals.map(t => t.weeks)); const rest = totals.reduce((s, t) => s + t.weeks, 0) - longest; // parallelised estimate: longest + 35% of the rest const total = Math.round((longest + rest * 0.35) * 10) / 10; return { total, perStudy: totals }; } function ProposalBuilder({ allTechniques, proposal, onOpenTechnique }) { const { cart, removeFromCart, clearCart, ctx, setCtxField } = proposal; const [view, setView] = React.useState('setup'); // setup | document const selected = cart.map(id => allTechniques.find(t => t.id === id)).filter(Boolean); const docRef = React.useRef(null); const infoRef = React.useRef(null); const wrapRef = React.useRef(null); const [scale, setScale] = React.useState(1); const [wrapH, setWrapH] = React.useState(0); const [exporting, setExporting] = React.useState(false); const INFO_W = 1100; // Fit the fixed-width infographic into the available modal width React.useEffect(() => { const fit = () => { if (!wrapRef.current || !infoRef.current) return; const avail = wrapRef.current.clientWidth; const s = Math.min(1, avail / INFO_W); setScale(s); setWrapH(infoRef.current.scrollHeight * s); }; fit(); const t1 = setTimeout(fit, 250); const t2 = setTimeout(fit, 800); let ro; if (window.ResizeObserver && wrapRef.current) { ro = new ResizeObserver(fit); ro.observe(wrapRef.current); } window.addEventListener('resize', fit); return () => { clearTimeout(t1); clearTimeout(t2); if (ro) ro.disconnect(); window.removeEventListener('resize', fit); }; }, [view, cart.join(','), JSON.stringify(ctx)]); const fill = txt => window.fillProposalTokens(txt, ctx); const tl = combinedTimeline(selected); const today = new Date().toLocaleDateString('en-GB', { day:'numeric', month:'long', year:'numeric' }); // Combined, de-duplicated deliverables / assumptions / risks const dedupe = arr => [...new Set(arr)]; const allDeliverables = dedupe(selected.flatMap(t => t.proposal.deliverables || [])); const allAssumptions = dedupe(selected.flatMap(t => t.proposal.assumptions || [])); const allRisks = dedupe(selected.flatMap(t => t.proposal.risks || [])); const buildPlainText = () => { const L = []; L.push('RESEARCH PROPOSAL'); if (ctx.clientName) L.push(`Prepared for: ${ctx.clientName}`); if (ctx.preparedBy) L.push(`Prepared by: ${ctx.preparedBy}`); L.push(`Date: ${today}`); L.push(''); L.push('1. BACKGROUND & OBJECTIVES'); L.push(fill(backgroundText())); if (ctx.objectives) { L.push(''); L.push('Specific objectives:'); L.push(ctx.objectives); } L.push(''); L.push('2. RECOMMENDED APPROACH'); L.push(approachIntro()); selected.forEach((t, i) => { L.push(''); L.push(`2.${i+1} ${t.name}`); L.push(fill(t.proposal.s150)); L.push(''); L.push(`Scope of work: ${fill(t.proposal.scopeOfWork)}`); const ex = window.PROPOSAL_EXTRAS[t.id]; if (ex) { L.push(`Recommended sample: n=${ex.sampleSize.recommended} (minimum n=${ex.sampleSize.min}). ${ex.sampleSize.note}`); L.push(`Indicative duration: ~${ex.timeline.reduce((s,p)=>s+p.weeks,0)} weeks. ${ex.fieldwork}`); } }); L.push(''); L.push('3. TIMELINE'); L.push(`Estimated total programme duration: approximately ${tl.total} weeks (methods partially parallelised).`); L.push(''); L.push('4. DELIVERABLES'); allDeliverables.forEach(d => L.push(`• ${d}`)); L.push(''); L.push('5. SAMPLE & AUDIENCE'); L.push(`Audience: ${ctx.audience || 'as agreed with client'}.`); if (ctx.sampleSize) L.push(`Target sample: n=${ctx.sampleSize}.`); L.push(''); L.push('6. ASSUMPTIONS'); allAssumptions.forEach(a => L.push(`• ${a}`)); L.push(''); L.push('7. RISKS & DEPENDENCIES'); allRisks.forEach(r => L.push(`• ${r}`)); return L.join('\n'); }; const backgroundText = () => { const c = ctx.clientName || 'the client'; const p = ctx.productName || 'the asset'; const ind = ctx.indication ? ` in ${ctx.indication}` : ''; return `${c} is seeking robust market research evidence to support decision-making for ${p}${ind}. This proposal sets out a recommended analytical approach designed to answer the key business questions with rigour and to deliver clear, actionable guidance for commercial and medical strategy.`; }; const approachIntro = () => { if (selected.length === 1) return `We recommend a focused study using the following methodology:`; return `We recommend an integrated programme combining ${selected.length} complementary techniques. Together these methods address the research objectives from multiple angles, ensuring findings are both robust and directly actionable:`; }; const printDoc = () => { document.body.classList.add('printing-proposal'); window.print(); setTimeout(() => document.body.classList.remove('printing-proposal'), 500); }; // ---- SETUP VIEW ---- if (view === 'setup') { return (
{/* Selected methods */} Selected Techniques ({selected.length}) {selected.length === 0 ? (
No techniques added yet.
Open any technique's Proposal tab and click "+ Add to Proposal", or add below.
) : (
{selected.map(t => { const ex = window.PROPOSAL_EXTRAS[t.id]; return (
{t.icon}
{t.name}
{ex &&
n={ex.sampleSize.recommended} · ~{ex.timeline.reduce((s,p)=>s+p.weeks,0)} wks · {ex.effort} effort
}
); })}
)} {/* Quick-add */}
Quick add
{allTechniques.filter(t => !cart.includes(t.id)).map(t => ( ))} {allTechniques.filter(t => !cart.includes(t.id)).length === 0 && All techniques added.}
{/* Context form */} Project Context
These details auto-fill into the proposal text and document header. All optional — leave blank to use generic placeholders.
setCtxField('clientName',v)} placeholder="e.g. Northwind Pharma" /> setCtxField('preparedBy',v)} placeholder="e.g. Insights & Analytics Team" /> setCtxField('productName',v)} placeholder="e.g. NW-204" /> setCtxField('indication',v)} placeholder="e.g. severe asthma" /> setCtxField('audience',v)} placeholder="e.g. UK & DE pulmonologists" />
Currency
setCtxField('sampleSize',v)} placeholder="e.g. 220" />
Specific objectives (optional)