// 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)
{selected.length > 0
?
: }
);
}
// ---- DOCUMENT VIEW (infographic) ----
const PE = window.PROPOSAL_EXTRAS;
const programmeTitle = (ctx.productName ? `${ctx.productName} — ` : '') +
(selected.length === 1 ? `${selected[0].name} Study` : 'Integrated Research Programme');
const maxWeeks = Math.max(...selected.map(t => { const ex = PE[t.id]; return ex ? ex.timeline.reduce((s,p)=>s+p.weeks,0) : 6; }), 1);
const tickStep = maxWeeks > 12 ? 2 : 1;
const ticks = []; for (let w = 0; w <= Math.ceil(maxWeeks); w += tickStep) ticks.push(w);
const metrics = [
{ icon:'◫', value: selected.length, label: selected.length === 1 ? 'Method' : 'Methods' },
{ icon:'◷', value: `~${tl.total}`, label: 'Weeks (est.)' },
{ icon:'◉', value: ctx.sampleSize ? `n=${ctx.sampleSize}` : 'Per method', label: 'Sample' },
{ icon:'✦', value: allDeliverables.length, label: 'Deliverables' },
];
const slug = s => (s || 'research').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
const exportImage = async () => {
const node = infoRef.current;
if (!node || !window.htmlToImage) { alert('Image export library not loaded.'); return; }
setExporting(true);
try {
const dataUrl = await window.htmlToImage.toPng(node, {
pixelRatio: 2, backgroundColor: '#ffffff', cacheBust: true,
width: node.offsetWidth, height: node.offsetHeight,
style: { transform: 'none', transformOrigin: 'top left', margin: '0' },
});
const a = document.createElement('a');
a.download = `${slug(ctx.productName || ctx.clientName)}-proposal.png`;
a.href = dataUrl; a.click();
} catch (e) { console.error(e); alert('Export failed: ' + e.message); }
setExporting(false);
};
const NAVY = '#1A1840';
return (
{/* Action bar */}
Exports as a high-resolution PNG (~2200px wide) — drop it straight into your deck or proposal.
{/* Scaled preview wrapper */}
{/* HEADER BAND */}
{/* decorative dots */}
{programmeTitle}
{ctx.clientName && Prepared for {ctx.clientName}}
{ctx.indication && · {ctx.indication}}
{ctx.audience && · {ctx.audience}}
{ctx.preparedBy && · by {ctx.preparedBy}}
{/* accent multi-colour rule */}
{/* METRIC TILES */}
{metrics.map((m,i) => (
))}
{/* BODY */}
{/* Objectives */}
{fill(backgroundText())}
{ctx.objectives && (
OBJECTIVES
{ctx.objectives}
)}
{/* Approach cards */}
{approachIntro()}
{selected.map((t,i) => {
const ex = PE[t.id];
return (
{String(i+1).padStart(2,'0')}
{fill(t.tagline)}.
{ex && }
{ex && s+p.weeks,0)} wks`} color={t.color} />}
{(t.tags.outputTypes||[]).slice(0,1).map((o,k)=> )}
);
})}
{/* Gantt timeline */}
~{tl.total} wks
TOTAL (PARALLELISED)
}>
{/* tick header */}
{ticks.map(w => (
{w===0?'Wk 0':w}
))}
{/* rows */}
{selected.map(t => {
const ex = PE[t.id]; if (!ex) return null;
const total = ex.timeline.reduce((s,p)=>s+p.weeks,0);
return (
{t.shortName}
{total} weeks
{ex.timeline.map((p,j) => {
const frac = p.weeks/total;
return (
{frac > 0.17 ? p.phase.split(' ')[0] : ''}
);
})}
);
})}
{/* phase legend (representative) */}
{selected[0] && PE[selected[0].id] && (
{PE[selected[0].id].timeline.map((p,j) => (
{p.phase}
))}
)}
{/* Deliverables */}
{allDeliverables.map((d,i) => (
✓
{d}
))}
{/* Assumptions + Risks */}
ASSUMPTIONS
{allAssumptions.map((a,i) => (
•{a}
))}
RISKS & DEPENDENCIES
{allRisks.map((r,i) => (
⚠{r}
))}
{/* FOOTER */}
⚗
Analytical Technique Library
Indicative figures — confirmed at kick-off. Sample illustrative data.
);
}
// --- small helpers ---
function tabBtn(active, disabled) {
return {
flex:1, padding:'8px 12px', borderRadius:7, border:'none',
background: active ? 'white' : 'transparent',
color: active ? COLORS.indigo[700] : disabled ? COLORS.slate[300] : COLORS.slate[500],
fontSize:12.5, fontWeight: active ? 700 : 600,
cursor: disabled ? 'not-allowed' : 'pointer',
boxShadow: active ? '0 1px 3px rgba(0,0,0,0.1)' : 'none'
};
}
function FieldLabel({ children }) {
return {children}
;
}
function Field({ label, value, onChange, placeholder }) {
return (
{label}
onChange(e.target.value)} placeholder={placeholder}
style={{ width:'100%', padding:'8px 12px', borderRadius:8, border:`1px solid ${COLORS.slate[200]}`,
fontSize:13, boxSizing:'border-box', color:COLORS.slate[800], outline:'none', fontFamily:'inherit' }} />
);
}
function DocSection({ n, title, children, last }) {
return (
{n}
{title}
{children}
);
}
function DocChip({ label, value, wide }) {
return (
{label}:
{value}
);
}
// Infographic section block with numbered marker + optional aside
function InfoBlock({ n, title, color, aside, children }) {
return (
);
}
// Small labelled fact pill used in approach cards
function FactPill({ label, value, color }) {
return (
{label}
{value}
);
}
// darken a hex colour progressively for timeline phases
function shade(hex, step) {
const f = parseInt(hex.slice(1), 16);
let r = (f >> 16) & 255, g = (f >> 8) & 255, b = f & 255;
const factor = 1 - Math.min(step * 0.11, 0.55);
r = Math.round(r * factor); g = Math.round(g * factor); b = Math.round(b * factor);
return `rgb(${r},${g},${b})`;
}
Object.assign(window, { useProposal, ProposalBuilder });