130 lines
4.7 KiB
JavaScript
130 lines
4.7 KiB
JavaScript
const sample = {
|
||
idea: 'I’m building a lightweight product feedback tool for people with too many ideas and too many possible features. It should feel useful before becoming a workspace.',
|
||
optionsText: `- Ranked feedback map for pasted feature lists
|
||
- Expert reflections on the top options
|
||
- Accounts and saved workspaces
|
||
- Team voting on feature priority
|
||
- Exportable decision brief for Slack or Notion
|
||
- Custom criteria builder
|
||
- Paid deeper product strategy pass`,
|
||
context: 'MVP, solo builder, needs to feel valuable in under two minutes, avoid dashboard swamp.',
|
||
mode: 'progress',
|
||
};
|
||
|
||
const form = document.querySelector('#rankForm');
|
||
const results = document.querySelector('#results');
|
||
const toastEl = document.querySelector('#toast');
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||
}
|
||
|
||
function metricPct(value) {
|
||
return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 10)));
|
||
}
|
||
|
||
function toast(message) {
|
||
toastEl.textContent = message;
|
||
toastEl.hidden = false;
|
||
clearTimeout(toastEl._timer);
|
||
toastEl._timer = setTimeout(() => { toastEl.hidden = true; }, 2600);
|
||
}
|
||
|
||
function fillSample() {
|
||
form.idea.value = sample.idea;
|
||
form.optionsText.value = sample.optionsText;
|
||
form.context.value = sample.context;
|
||
form.mode.value = sample.mode;
|
||
document.querySelector('#try')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
toast('Sample loaded. Run it or replace it with your own mess.');
|
||
}
|
||
|
||
function laneClass(lane) {
|
||
return `lane-${lane?.id || 'defer'}`;
|
||
}
|
||
|
||
function renderMetrics(metrics) {
|
||
const items = [
|
||
['Value', metrics.value],
|
||
['Feasible', metrics.feasibility],
|
||
['Confidence', metrics.confidence],
|
||
['Revenue', metrics.revenue],
|
||
['Risk', metrics.risk],
|
||
];
|
||
return `<div class="metrics">${items.map(([label, value]) => `
|
||
<div class="metric"><span>${label}</span><i><b style="width:${metricPct(value)}%"></b></i></div>
|
||
`).join('')}</div>`;
|
||
}
|
||
|
||
function renderResults(data) {
|
||
const ranked = data.ranked || [];
|
||
const brief = data.brief || {};
|
||
results.hidden = false;
|
||
results.innerHTML = `
|
||
<div class="results-head">
|
||
<p class="eyebrow">${escapeHtml(data.mode?.label || 'Ranked feedback')}</p>
|
||
<h2>${escapeHtml(brief.headline || 'Ranked feedback map')}</h2>
|
||
<p>${escapeHtml(brief.summary || '')}</p>
|
||
</div>
|
||
|
||
<div class="ranked-list">
|
||
${ranked.map((item) => `
|
||
<article class="rank-card ${laneClass(item.lane)}">
|
||
<div class="rank-topline">
|
||
<span class="rank-number">#${item.rank}</span>
|
||
<span class="lane-pill">${escapeHtml(item.lane?.label || 'Ranked')}</span>
|
||
<strong>${item.metrics.score}</strong>
|
||
</div>
|
||
<h3>${escapeHtml(item.title)}</h3>
|
||
${item.description && item.description !== item.title ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ''}
|
||
<p><b>Why:</b> ${escapeHtml(item.reason)}.</p>
|
||
<p><b>Concern:</b> ${escapeHtml(item.concern)}</p>
|
||
${renderMetrics(item.metrics)}
|
||
</article>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div class="brief-grid">
|
||
<article class="brief-card next-card">
|
||
<span>Next 48 hours</span>
|
||
<ol>${(brief.next48Hours || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
|
||
</article>
|
||
${(brief.expertReflections || []).map((reflection) => `
|
||
<article class="brief-card">
|
||
<span>${escapeHtml(reflection.lens)}</span>
|
||
<p>${escapeHtml(reflection.text)}</p>
|
||
</article>
|
||
`).join('')}
|
||
</div>
|
||
<p class="caution">${escapeHtml(brief.caution || 'First-pass judgement, not an oracle.')}</p>
|
||
`;
|
||
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
async function createFeedbackMap(event) {
|
||
event.preventDefault();
|
||
const submit = form.querySelector('button[type="submit"]');
|
||
const payload = Object.fromEntries(new FormData(form).entries());
|
||
submit.disabled = true;
|
||
submit.textContent = 'Ranking…';
|
||
try {
|
||
const response = await fetch('/api/rank-feedback', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || 'Could not rank this list.');
|
||
renderResults(data);
|
||
} catch (error) {
|
||
toast(error.message);
|
||
} finally {
|
||
submit.disabled = false;
|
||
submit.textContent = 'Create ranked feedback map';
|
||
}
|
||
}
|
||
|
||
form.addEventListener('submit', createFeedbackMap);
|
||
document.querySelector('#loadSample')?.addEventListener('click', fillSample);
|
||
document.querySelector('#loadSampleTop')?.addEventListener('click', fillSample);
|