Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML
- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII) - CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19) - portal/index.html + ops-portal/: customer portal + ops portal single-file builds - .gitignore: demo-creds*.txt excluded
This commit is contained in:
@@ -0,0 +1,889 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VerdictTank | Client Portal</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg2: #fafafa;
|
||||
--text: #171717;
|
||||
--text2: #525252;
|
||||
--text3: #737373;
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #b91c1c;
|
||||
--accent-light: #fef2f2;
|
||||
--border: #e5e5e5;
|
||||
--green: #16a34a;
|
||||
--lime: #65a30d;
|
||||
--amber: #ca8a04;
|
||||
--orange: #ea580c;
|
||||
--red: #b91c1c;
|
||||
--card: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #0a0a0a;
|
||||
--bg2: #141414;
|
||||
--text: #ededed;
|
||||
--text2: #a1a1a1;
|
||||
--text3: #8a8a8a;
|
||||
--accent: #ef4444;
|
||||
--accent-hover: #dc2626;
|
||||
--accent-light: #2a1212;
|
||||
--border: #262626;
|
||||
--card: #141414;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.5), 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.container { width: 100%; max-width: 1040px; margin: 0 auto; padding: 0 1.25rem; }
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: var(--bg);
|
||||
}
|
||||
.header-inner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 60px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 800; font-size: 1.1rem; letter-spacing: -0.02em; color: var(--text); }
|
||||
.brand .mark {
|
||||
width: 26px; height: 26px; border-radius: 7px; background: var(--accent);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 0.8rem; font-weight: 800;
|
||||
}
|
||||
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.user-chip { font-size: 0.85rem; color: var(--text2); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||
padding: 0.55rem 1.1rem; border-radius: 8px; border: 1px solid transparent;
|
||||
font-weight: 600; font-size: 0.9rem; cursor: pointer; font-family: inherit;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
text-decoration: none; line-height: 1.2;
|
||||
}
|
||||
.btn:hover { text-decoration: none; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn-secondary { background: transparent; color: var(--text); border-color: var(--border); }
|
||||
.btn-secondary:hover { border-color: var(--text3); }
|
||||
.btn-ghost { background: transparent; color: var(--text2); border: none; padding: 0.4rem 0.6rem; }
|
||||
.btn-ghost:hover { color: var(--text); background: var(--accent-light); }
|
||||
.btn-sm { padding: 0.35rem 0.8rem; font-size: 0.82rem; }
|
||||
.icon-btn {
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 8px;
|
||||
width: 34px; height: 34px; cursor: pointer; color: var(--text2); font-size: 0.95rem;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-family: inherit;
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); border-color: var(--text3); }
|
||||
|
||||
/* Cards & panels */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 1.5rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.panel-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||||
.panel-sub { color: var(--text3); font-size: 0.85rem; margin-bottom: 1.25rem; }
|
||||
|
||||
/* Forms */
|
||||
.field { margin-bottom: 1.1rem; }
|
||||
.field label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text2); }
|
||||
.field .hint { font-size: 0.78rem; color: var(--text3); margin-top: 0.3rem; }
|
||||
input[type="text"], input[type="email"], input[type="password"], textarea, select {
|
||||
width: 100%; padding: 0.6rem 0.75rem; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--bg); color: var(--text);
|
||||
font-family: inherit; font-size: 0.9rem;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 130px; }
|
||||
.form-error {
|
||||
background: var(--accent-light); border: 1px solid var(--accent);
|
||||
color: var(--accent); border-radius: 8px; padding: 0.7rem 0.9rem;
|
||||
font-size: 0.85rem; margin-bottom: 1rem; display: none;
|
||||
}
|
||||
.form-error.show { display: block; }
|
||||
|
||||
/* Auth */
|
||||
.auth-wrap { max-width: 420px; margin: 3rem auto; }
|
||||
.auth-tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
||||
.auth-tab {
|
||||
flex: 1; text-align: center; padding: 0.7rem; cursor: pointer; font-weight: 600;
|
||||
color: var(--text3); border-bottom: 2px solid transparent; background: transparent;
|
||||
border-top: none; border-left: none; border-right: none; font-family: inherit; font-size: 0.9rem;
|
||||
}
|
||||
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Dashboard */
|
||||
.dash-head { display: flex; align-items: center; justify-content: space-between; margin: 2rem 0 1.5rem; flex-wrap: wrap; gap: 1rem; }
|
||||
.dash-head h1 { font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.dash-head .sub { color: var(--text3); font-size: 0.9rem; margin-top: 0.2rem; }
|
||||
table.reviews { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
table.reviews th {
|
||||
text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text3); padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
table.reviews td { padding: 0.85rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
table.reviews tr:last-child td { border-bottom: none; }
|
||||
table.reviews tbody tr:hover { background: var(--bg2); }
|
||||
.prop-name { font-weight: 600; color: var(--text); }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text3); }
|
||||
.empty-state h3 { color: var(--text); font-size: 1.1rem; margin-bottom: 0.4rem; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.02em;
|
||||
}
|
||||
.badge-queued { background: var(--accent-light); color: var(--accent); }
|
||||
.badge-processing { background: #fef3c7; color: #b45309; }
|
||||
.badge-complete { background: #f0fdf4; color: #15803d; }
|
||||
.badge-failed { background: var(--accent-light); color: var(--red); }
|
||||
[data-theme="dark"] .badge-processing { background: #2a1e08; color: #f59e0b; }
|
||||
[data-theme="dark"] .badge-complete { background: #0a2a16; color: #4ade80; }
|
||||
|
||||
/* Score mini */
|
||||
.score-mini { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Tier selector */
|
||||
.tier-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.6rem; }
|
||||
.tier-opt {
|
||||
border: 1px solid var(--border); border-radius: 10px; padding: 0.85rem 0.9rem; cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s; background: var(--bg);
|
||||
}
|
||||
.tier-opt:hover { border-color: var(--text3); }
|
||||
.tier-opt.selected { border-color: var(--accent); background: var(--accent-light); }
|
||||
.tier-opt input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.tier-opt .tier-name { font-weight: 700; font-size: 0.88rem; }
|
||||
.tier-opt .tier-price { color: var(--text3); font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
|
||||
/* Results */
|
||||
.score-hero-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.25rem 0; }
|
||||
.score-card { text-align: center; padding: 1.5rem 1rem; border-radius: 12px; border: 1px solid var(--border); background: var(--bg2); }
|
||||
.score-card .num { font-size: 3rem; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.score-card .num span { font-size: 1rem; color: var(--text3); font-weight: 600; }
|
||||
.score-card .lbl { font-weight: 700; margin-top: 0.4rem; font-size: 0.95rem; }
|
||||
.score-card .blurb { color: var(--text3); font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
.verdict-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
||||
padding: 1rem 1.25rem; border-radius: 10px; margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap;
|
||||
}
|
||||
.verdict-flag { display: inline-block; padding: 0.3rem 0.9rem; border-radius: 6px; color: #fff; font-weight: 800; font-size: 0.85rem; letter-spacing: 0.03em; }
|
||||
.verdict-composite { font-weight: 800; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
|
||||
.section { margin: 2rem 0; }
|
||||
.section h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
.prose { color: var(--text2); }
|
||||
.prose p { margin-bottom: 0.75rem; }
|
||||
|
||||
/* Dimension table */
|
||||
table.dims { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
table.dims th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text3); padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
table.dims td { padding: 0.6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
table.dims tr:last-child td { border-bottom: none; }
|
||||
table.dims .dim-score { font-weight: 700; font-variant-numeric: tabular-nums; width: 70px; text-align: right; }
|
||||
.bar-track { background: var(--border); border-radius: 4px; height: 8px; width: 100%; }
|
||||
.bar-fill { height: 8px; border-radius: 4px; }
|
||||
|
||||
/* Flaws & actions */
|
||||
.flaw { border-left: 3px solid var(--border); padding: 0.6rem 0.9rem; margin: 0.8rem 0; }
|
||||
.flaw .sev { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; color: #fff; margin-right: 0.5rem; }
|
||||
.sev-fatal { background: var(--red); } .sev-major { background: var(--orange); }
|
||||
.sev-moderate { background: var(--amber); } .sev-minor { background: var(--text3); }
|
||||
.flaw .issue { font-weight: 600; }
|
||||
.flaw .why { color: var(--text3); font-size: 0.84rem; margin-top: 0.25rem; }
|
||||
.flaw .fix { color: var(--green); font-size: 0.84rem; margin-top: 0.25rem; }
|
||||
.action { padding: 0.6rem 0.9rem; margin: 0.5rem 0; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.action .n { display: inline-block; background: var(--accent); color: #fff; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 6px; font-size: 0.78rem; font-weight: 800; margin-right: 0.5rem; }
|
||||
.action .rationale { color: var(--text3); font-size: 0.84rem; margin-top: 0.25rem; }
|
||||
ul.bullets { padding-left: 1.2rem; }
|
||||
ul.bullets li { margin: 0.4rem 0; color: var(--text2); }
|
||||
.callout { border: 1px solid var(--amber); background: #fefce8; border-radius: 10px; padding: 0.9rem 1.1rem; }
|
||||
[data-theme="dark"] .callout { background: #1f1a05; }
|
||||
|
||||
/* Progress / status */
|
||||
.status-line { display: flex; align-items: center; gap: 0.7rem; padding: 1rem 1.25rem; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 1.25rem; }
|
||||
.spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Download actions */
|
||||
.dl-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
|
||||
/* Footer */
|
||||
footer { margin-top: 3rem; border-top: 1px solid var(--border); padding: 1.5rem 0 2.5rem; }
|
||||
footer p { color: var(--text3); font-size: 0.85rem; }
|
||||
footer strong { color: var(--text2); }
|
||||
footer a { color: var(--accent); }
|
||||
|
||||
/* Utility */
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--text3); }
|
||||
.mt-1 { margin-top: 0.5rem; } .mt-2 { margin-top: 1rem; } .mt-3 { margin-top: 1.5rem; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
|
||||
main { flex: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.score-hero-grid { grid-template-columns: 1fr; }
|
||||
table.reviews th:nth-child(3), table.reviews td:nth-child(3),
|
||||
table.reviews th:nth-child(5), table.reviews td:nth-child(5) { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="#dashboard"><span class="mark">V</span> VerdictTank</a>
|
||||
<div class="header-actions">
|
||||
<span class="user-chip" id="userChip"></span>
|
||||
<button class="btn btn-ghost btn-sm hidden" id="btnLogout">Sign out</button>
|
||||
<button class="icon-btn" id="btnTheme" aria-label="Toggle theme" title="Toggle light / dark">◐</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="container">
|
||||
|
||||
<!-- AUTH VIEW -->
|
||||
<section id="view-auth" class="hidden">
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" id="tabLogin" data-mode="login">Sign In</button>
|
||||
<button class="auth-tab" id="tabRegister" data-mode="register">Create Account</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="form-error" id="authError"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="field">
|
||||
<label for="loginEmail">Email</label>
|
||||
<input type="email" id="loginEmail" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="loginPassword">Password</label>
|
||||
<input type="password" id="loginPassword" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%" id="btnLogin">Sign In</button>
|
||||
</form>
|
||||
|
||||
<form id="registerForm" class="hidden">
|
||||
<div class="field">
|
||||
<label for="regName">Full name</label>
|
||||
<input type="text" id="regName" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="regEmail">Email</label>
|
||||
<input type="email" id="regEmail" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="regPassword">Password</label>
|
||||
<input type="password" id="regPassword" autocomplete="new-password" minlength="8" required>
|
||||
<div class="hint">At least 8 characters.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%" id="btnRegister">Create Account</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DASHBOARD VIEW -->
|
||||
<section id="view-dashboard" class="hidden">
|
||||
<div class="dash-head">
|
||||
<div>
|
||||
<h1>Your reviews</h1>
|
||||
<div class="sub" id="dashSub">Review history for your account.</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnNewReview">+ Start a review</button>
|
||||
</div>
|
||||
<div class="card" style="padding:0.5rem 1rem 1rem">
|
||||
<div id="reviewsBody"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SUBMIT VIEW -->
|
||||
<section id="view-submit" class="hidden">
|
||||
<div class="dash-head">
|
||||
<div>
|
||||
<h1>Submit a proposal</h1>
|
||||
<div class="sub">Your document is reviewed by an independent panel of nine review providers.</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="btnSubmitBack">Back to reviews</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="form-error" id="submitError"></div>
|
||||
<form id="submitForm">
|
||||
<div class="field">
|
||||
<label for="proposalName">Proposal name</label>
|
||||
<input type="text" id="proposalName" required placeholder="e.g. Atlas fleet telematics platform">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="submitName">Your name</label>
|
||||
<input type="text" id="submitName" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="submitEmail">Email</label>
|
||||
<input type="email" id="submitEmail" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Review tier</label>
|
||||
<div class="tier-grid" id="tierGrid"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="proposalText">Proposal description</label>
|
||||
<textarea id="proposalText" placeholder="Describe your proposal: the problem, market, model, and why it will work. The more detail you provide, the sharper the review."></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="proposalFile">Attach a document (optional)</label>
|
||||
<input type="file" id="proposalFile" accept=".pdf,.docx,.txt,.md,.doc">
|
||||
<div class="hint">PDF, DOCX, TXT, or Markdown.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<p class="muted" style="font-size:0.82rem">Your documents are processed by nine independent review providers. Eight of the nine do not train on your content. See the <a href="https://proposals.itpropartner.com/verdicttank/data-handling.html" rel="noopener">data-handling disclosure</a> for the one exception and our retention policy.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="btnSubmit">Submit for review</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RESULTS VIEW -->
|
||||
<section id="view-results" class="hidden">
|
||||
<div class="dash-head">
|
||||
<div>
|
||||
<h1 id="resultsTitle">Review</h1>
|
||||
<div class="sub" id="resultsMeta"></div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="btnResultsBack">Back to reviews</button>
|
||||
</div>
|
||||
<div id="resultsBody"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p><strong>VerdictTank</strong>, a product of IT Pro Partner</p>
|
||||
<p style="margin-top:0.5rem;">
|
||||
<a href="https://verdicttank.com">VerdictTank</a> ·
|
||||
<a href="https://itpropartner.com">IT Pro Partner</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_BASE = 'https://verdicttank.com/api/verdicttank';
|
||||
|
||||
var TIERS = [
|
||||
{ key: 'free', label: 'Free Assessment', price: '$0' },
|
||||
{ key: 'oneshot', label: 'One Shot Review', price: '$29' },
|
||||
{ key: 'pro', label: 'Pro Review', price: '$119/mo' },
|
||||
{ key: 'enterprise', label: 'Enterprise Review', price: '$699/mo' },
|
||||
{ key: 'whitelabel', label: 'White Label Review', price: '$3,000/mo' }
|
||||
];
|
||||
|
||||
var GROUPS = {
|
||||
proposal_strength: {
|
||||
label: 'Proposal Strength',
|
||||
blurb: 'How well the document makes its case.',
|
||||
keys: ['problem_clarity', 'market_reality', 'differentiation', 'business_model', 'evidence_quality']
|
||||
},
|
||||
investor_readiness: {
|
||||
label: 'Investor Readiness',
|
||||
blurb: 'How well the business survives scrutiny.',
|
||||
keys: ['financial_credibility', 'execution_feasibility', 'team_capability', 'legal_risk', 'defensibility']
|
||||
}
|
||||
};
|
||||
|
||||
var VERDICT_BANDS = [
|
||||
[80, 'STRONG', '#15803d'],
|
||||
[65, 'PROMISING', '#65a30d'],
|
||||
[50, 'CONDITIONAL', '#ca8a04'],
|
||||
[35, 'WEAK', '#ea580c'],
|
||||
[0, 'NOT VIABLE', '#b91c1c']
|
||||
];
|
||||
|
||||
var state = {
|
||||
token: localStorage.getItem('vt_token') || null,
|
||||
user: safeJson(localStorage.getItem('vt_user'))
|
||||
};
|
||||
|
||||
var pollTimer = null;
|
||||
|
||||
// ---------- helpers ----------
|
||||
function safeJson(s) {
|
||||
try { return s ? JSON.parse(s) : null; } catch (e) { return null; }
|
||||
}
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function $id(id) { return document.getElementById(id); }
|
||||
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
if (state.token) opts.headers['Authorization'] = 'Bearer ' + state.token;
|
||||
var isForm = !!(opts.body && opts.body instanceof FormData);
|
||||
if (opts.body && !isForm) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return fetch(API_BASE + path, opts).then(function (r) {
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
var p = ct.indexOf('application/json') >= 0
|
||||
? r.json()
|
||||
: r.text().then(function (t) { return { _text: t }; });
|
||||
return p.then(function (data) {
|
||||
if (!r.ok) {
|
||||
var msg = (data && data.detail) ? data.detail : ('Request failed (' + r.status + ')');
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bandFor(score) {
|
||||
for (var i = 0; i < VERDICT_BANDS.length; i++) {
|
||||
if (score >= VERDICT_BANDS[i][0]) return VERDICT_BANDS[i];
|
||||
}
|
||||
return VERDICT_BANDS[VERDICT_BANDS.length - 1];
|
||||
}
|
||||
function dimColor(v) {
|
||||
if (v >= 8) return '#15803d';
|
||||
if (v >= 6.5) return '#65a30d';
|
||||
if (v >= 5) return '#ca8a04';
|
||||
if (v >= 3.5) return '#ea580c';
|
||||
return '#b91c1c';
|
||||
}
|
||||
|
||||
// ---------- theme ----------
|
||||
function initTheme() {
|
||||
var saved = localStorage.getItem('theme');
|
||||
if (saved === 'dark' || saved === 'light') {
|
||||
document.documentElement.dataset.theme = saved;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
}
|
||||
}
|
||||
function toggleTheme() {
|
||||
var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
|
||||
// ---------- view routing ----------
|
||||
var VIEWS = ['auth', 'dashboard', 'submit', 'results'];
|
||||
function showView(name) {
|
||||
VIEWS.forEach(function (v) {
|
||||
$id('view-' + v).classList.toggle('hidden', v !== name);
|
||||
});
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
var chip = $id('userChip');
|
||||
var logout = $id('btnLogout');
|
||||
if (state.user) {
|
||||
chip.textContent = state.user.email || '';
|
||||
logout.classList.remove('hidden');
|
||||
} else {
|
||||
chip.textContent = '';
|
||||
logout.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
function showAuthError(msg) { setError($id('authError'), msg); }
|
||||
function setError(el, msg) {
|
||||
if (!msg) { el.classList.remove('show'); el.textContent = ''; return; }
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
}
|
||||
|
||||
function onAuthed(data) {
|
||||
state.token = data.token;
|
||||
state.user = data.user;
|
||||
localStorage.setItem('vt_token', data.token);
|
||||
localStorage.setItem('vt_user', JSON.stringify(data.user));
|
||||
renderHeader();
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function doLogin(email, password) {
|
||||
showAuthError(null);
|
||||
api('/auth/login', { method: 'POST', body: { email: email, password: password } })
|
||||
.then(onAuthed)
|
||||
.catch(function (e) { showAuthError(e.message); });
|
||||
}
|
||||
function doRegister(name, email, password) {
|
||||
showAuthError(null);
|
||||
api('/auth/register', { method: 'POST', body: { email: email, password: password, name: name } })
|
||||
.then(onAuthed)
|
||||
.catch(function (e) { showAuthError(e.message); });
|
||||
}
|
||||
|
||||
// ---------- dashboard ----------
|
||||
function loadDashboard() {
|
||||
showView('dashboard');
|
||||
$id('dashSub').textContent = state.user ? ('Signed in as ' + state.user.email) : 'Review history for your account.';
|
||||
var body = $id('reviewsBody');
|
||||
body.innerHTML = '<p class="muted" style="padding:1rem">Loading reviews...</p>';
|
||||
api('/reviews').then(function (data) {
|
||||
var reviews = (data && data.reviews) || [];
|
||||
if (!reviews.length) {
|
||||
body.innerHTML = '<div class="empty-state"><h3>No reviews yet</h3><p>Submit your first proposal to get an independent panel review.</p><button class="btn btn-primary mt-2" id="btnEmptyNew">+ Start a review</button></div>';
|
||||
var b = $id('btnEmptyNew'); if (b) b.addEventListener('click', renderSubmit);
|
||||
return;
|
||||
}
|
||||
body.innerHTML = '<table class="reviews"><thead><tr><th>Proposal</th><th>Status</th><th>Score</th><th>Submitted</th><th></th></tr></thead><tbody>' +
|
||||
reviews.map(function (r) {
|
||||
var statusBadge = badgeFor(r.status);
|
||||
var scoreCell = '';
|
||||
if (r.status === 'complete' && typeof r.composite === 'number') {
|
||||
var band = bandFor(r.composite);
|
||||
scoreCell = '<span class="score-mini" style="color:' + band[2] + '">' + r.composite + '</span> / 100';
|
||||
} else {
|
||||
scoreCell = '<span class="muted">-</span>';
|
||||
}
|
||||
var when = formatDate(r.submitted_at);
|
||||
return '<tr>' +
|
||||
'<td><span class="prop-name">' + esc(r.proposal_name || 'Untitled') + '</span><br><span class="muted" style="font-size:0.78rem">' + esc(r.tier_label || '') + '</span></td>' +
|
||||
'<td>' + statusBadge + '</td>' +
|
||||
'<td>' + scoreCell + '</td>' +
|
||||
'<td class="muted">' + esc(when) + '</td>' +
|
||||
'<td style="text-align:right"><button class="btn btn-secondary btn-sm" data-view="' + esc(r.review_id) + '">View</button></td>' +
|
||||
'</tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
body.querySelectorAll('[data-view]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { enterResults(b.getAttribute('data-view')); });
|
||||
});
|
||||
}).catch(function (e) {
|
||||
body.innerHTML = '<div class="empty-state"><h3>Could not load reviews</h3><p>' + esc(e.message) + '</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function badgeFor(status) {
|
||||
var map = {
|
||||
queued: 'badge-queued',
|
||||
processing: 'badge-processing',
|
||||
complete: 'badge-complete',
|
||||
failed: 'badge-failed'
|
||||
};
|
||||
var cls = map[status] || 'badge-queued';
|
||||
var label = (status || 'queued').charAt(0).toUpperCase() + (status || 'queued').slice(1);
|
||||
return '<span class="badge ' + cls + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------- submit ----------
|
||||
function renderSubmit() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
showView('submit');
|
||||
setError($id('submitError'), null);
|
||||
$id('proposalName').value = '';
|
||||
$id('proposalText').value = '';
|
||||
$id('proposalFile').value = '';
|
||||
if (state.user) {
|
||||
$id('submitName').value = state.user.name || '';
|
||||
$id('submitEmail').value = state.user.email || '';
|
||||
}
|
||||
var grid = $id('tierGrid');
|
||||
grid.innerHTML = TIERS.map(function (t) {
|
||||
return '<label class="tier-opt' + (t.key === 'free' ? ' selected' : '') + '">' +
|
||||
'<input type="radio" name="tier" value="' + t.key + '"' + (t.key === 'free' ? ' checked' : '') + '>' +
|
||||
'<div class="tier-name">' + esc(t.label) + '</div>' +
|
||||
'<div class="tier-price">' + esc(t.price) + '</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
grid.querySelectorAll('.tier-opt').forEach(function (opt) {
|
||||
opt.addEventListener('click', function () {
|
||||
grid.querySelectorAll('.tier-opt').forEach(function (o) { o.classList.remove('selected'); });
|
||||
opt.classList.add('selected');
|
||||
opt.querySelector('input').checked = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function doSubmit(form) {
|
||||
setError($id('submitError'), null);
|
||||
var name = $id('submitName').value.trim();
|
||||
var email = $id('submitEmail').value.trim();
|
||||
var proposalName = $id('proposalName').value.trim();
|
||||
var proposalText = $id('proposalText').value.trim();
|
||||
var fileInput = $id('proposalFile');
|
||||
var tier = (form.querySelector('input[name="tier"]:checked') || {}).value || 'free';
|
||||
|
||||
if (!proposalName) { setError($id('submitError'), 'Proposal name is required.'); return; }
|
||||
if (!name) { setError($id('submitError'), 'Your name is required.'); return; }
|
||||
if (!email) { setError($id('submitError'), 'A valid email address is required.'); return; }
|
||||
if (!proposalText && !(fileInput.files && fileInput.files.length)) {
|
||||
setError($id('submitError'), 'Provide a proposal description or attach a document.'); return;
|
||||
}
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('email', email);
|
||||
fd.append('proposal_name', proposalName);
|
||||
fd.append('proposal_text', proposalText);
|
||||
fd.append('tier', tier);
|
||||
if (fileInput.files && fileInput.files.length) {
|
||||
fd.append('proposal_file', fileInput.files[0]);
|
||||
}
|
||||
|
||||
var btn = $id('btnSubmit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
api('/submit', { method: 'POST', body: fd })
|
||||
.then(function (res) {
|
||||
enterResults(res.review_id);
|
||||
})
|
||||
.catch(function (e) {
|
||||
setError($id('submitError'), e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit for review';
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- results ----------
|
||||
function enterResults(reviewId) {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
showView('results');
|
||||
$id('resultsTitle').textContent = 'Review ' + reviewId;
|
||||
$id('resultsMeta').textContent = '';
|
||||
$id('resultsBody').innerHTML = '<div class="status-line"><div class="spinner"></div><span>Loading review status...</span></div>';
|
||||
pollReview(reviewId);
|
||||
}
|
||||
|
||||
function pollReview(reviewId) {
|
||||
function tick() {
|
||||
api('/status/' + reviewId).then(function (s) {
|
||||
if (s.status === 'complete') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
loadResults(reviewId);
|
||||
} else if (s.status === 'failed') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
renderFailed(reviewId);
|
||||
} else {
|
||||
renderProgress(s);
|
||||
}
|
||||
}).catch(function (e) {
|
||||
$id('resultsBody').innerHTML = '<div class="status-line"><div class="spinner"></div><span>Could not reach the review service. Retrying...</span></div>';
|
||||
});
|
||||
}
|
||||
tick();
|
||||
if (pollTimer) { clearInterval(pollTimer); }
|
||||
pollTimer = setInterval(tick, 8000);
|
||||
}
|
||||
|
||||
function renderProgress(s) {
|
||||
var progress = s.progress || 'Waiting in queue...';
|
||||
$id('resultsBody').innerHTML =
|
||||
'<div class="status-line"><div class="spinner"></div><span>' + esc(progress) + '</span></div>' +
|
||||
'<p class="muted">The panel is reviewing your proposal. This page updates automatically; you can leave and come back from your dashboard.</p>';
|
||||
}
|
||||
|
||||
function renderFailed(reviewId) {
|
||||
$id('resultsBody').innerHTML =
|
||||
'<div class="status-line"><span class="badge badge-failed">Failed</span><span>The review could not be completed.</span></div>' +
|
||||
'<button class="btn btn-secondary" onclick="window.location.reload()">Retry</button>';
|
||||
}
|
||||
|
||||
function loadResults(reviewId) {
|
||||
api('/results/' + reviewId).then(function (r) {
|
||||
renderResults(r);
|
||||
}).catch(function (e) {
|
||||
$id('resultsBody').innerHTML = '<div class="status-line"><span>Could not load results: ' + esc(e.message) + '</span></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(r) {
|
||||
$id('resultsTitle').textContent = r.proposal_name || ('Review ' + r.review_id);
|
||||
$id('resultsMeta').textContent = (r.tier_label || '') + ' · submitted ' + formatDate(r.submitted_at);
|
||||
|
||||
var composite = (typeof r.composite === 'number') ? r.composite : 0;
|
||||
var band = bandFor(composite);
|
||||
var verdictHtml = '<div class="verdict-banner">' +
|
||||
'<div><span class="verdict-flag" style="background:' + band[2] + '">' + esc(r.verdict || band[1]) + '</span>' +
|
||||
' <span class="muted" style="margin-left:0.4rem;font-size:0.85rem">Composite score</span></div>' +
|
||||
'<div class="verdict-composite" style="color:' + band[2] + '">' + composite + ' <span class="muted" style="font-size:0.85rem;font-weight:600">/ 100</span></div>' +
|
||||
'</div>';
|
||||
|
||||
var scoreCards = '<div class="score-hero-grid">' +
|
||||
scoreCard(r.proposal_strength, GROUPS.proposal_strength.label, GROUPS.proposal_strength.blurb) +
|
||||
scoreCard(r.investor_readiness, GROUPS.investor_readiness.label, GROUPS.investor_readiness.blurb) +
|
||||
'</div>';
|
||||
|
||||
var dimsByKey = {};
|
||||
(r.dimensions || []).forEach(function (d) { dimsByKey[d.key] = d; });
|
||||
|
||||
var dimSections = Object.keys(GROUPS).map(function (g) {
|
||||
var group = GROUPS[g];
|
||||
var rows = group.keys.map(function (k) {
|
||||
var d = dimsByKey[k];
|
||||
var label = (d && d.label) || k;
|
||||
var v = (d && typeof d.score === 'number') ? d.score : 0;
|
||||
var c = dimColor(v);
|
||||
return '<tr><td>' + esc(label) + '</td>' +
|
||||
'<td class="dim-score" style="color:' + c + '">' + v + ' / 10</td>' +
|
||||
'<td style="width:40%"><div class="bar-track"><div class="bar-fill" style="width:' + Math.max(2, v * 10) + '%;background:' + c + '"></div></div></td></tr>';
|
||||
}).join('');
|
||||
return '<div class="section"><h2>' + esc(group.label) + '</h2>' +
|
||||
'<table class="dims"><tbody>' + rows + '</tbody></table></div>';
|
||||
}).join('');
|
||||
|
||||
var summaryHtml = '';
|
||||
if (r.executive_summary) {
|
||||
summaryHtml = '<div class="section"><h2>Executive Summary</h2><div class="prose"><p>' + esc(r.executive_summary) + '</p></div></div>';
|
||||
}
|
||||
if (r.verdict_rationale) {
|
||||
summaryHtml += '<div class="section"><h2>Verdict Rationale</h2><div class="prose"><p>' + esc(r.verdict_rationale) + '</p></div></div>';
|
||||
}
|
||||
|
||||
var flawsHtml = '';
|
||||
var flaws = r.fatal_flaws || [];
|
||||
if (flaws.length) {
|
||||
flawsHtml = '<div class="section"><h2>Key Flaws</h2>' + flaws.map(function (f) {
|
||||
var sev = String(f.severity || 'major').toLowerCase();
|
||||
sev = ['fatal', 'major', 'moderate', 'minor'].indexOf(sev) >= 0 ? sev : 'major';
|
||||
var why = f.why ? '<div class="why">' + esc(f.why) + '</div>' : '';
|
||||
var fix = f.fix ? '<div class="fix">Corrective action: ' + esc(f.fix) + '</div>' : '';
|
||||
return '<div class="flaw"><span class="sev sev-' + sev + '">' + sev + '</span>' +
|
||||
'<span class="issue">' + esc(f.issue || '') + '</span>' + why + fix + '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var actions = r.action_plan || [];
|
||||
var actionsHtml = '';
|
||||
if (actions.length) {
|
||||
actionsHtml = '<div class="section"><h2>Priority Action Plan</h2>' + actions.map(function (a, i) {
|
||||
var rationale = a.rationale ? '<div class="rationale">' + esc(a.rationale) + '</div>' : '';
|
||||
return '<div class="action"><span class="n">' + (i + 1) + '</span><strong>' + esc(a.action || '') + '</strong>' + rationale + '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var consensus = r.consensus || [];
|
||||
var consensusHtml = '';
|
||||
if (consensus.length) {
|
||||
consensusHtml = '<div class="section"><h2>Panel Consensus</h2><ul class="bullets">' +
|
||||
consensus.map(function (c) { return '<li>' + esc(c) + '</li>'; }).join('') + '</ul></div>';
|
||||
}
|
||||
|
||||
var kills = r.kill_criteria || [];
|
||||
var killsHtml = '';
|
||||
if (kills.length) {
|
||||
killsHtml = '<div class="section"><h2>Kill Criteria</h2><div class="callout">' +
|
||||
'<p style="font-size:0.88rem;color:var(--text2)">If any of the following becomes true, the panel advises abandoning this proposal in its current form rather than continuing to invest in it.</p>' +
|
||||
'<ul class="bullets" style="margin-top:0.5rem">' +
|
||||
kills.map(function (k) { return '<li>' + esc(k) + '</li>'; }).join('') + '</ul></div>';
|
||||
}
|
||||
|
||||
var reportUrl = 'https://verdicttank.com/api/verdicttank/report/' + encodeURIComponent(r.review_id);
|
||||
var receiptUrl = 'https://verdicttank.com/api/receipt/' + encodeURIComponent(r.review_id);
|
||||
var dlHtml = '<div class="section"><h2>Documents</h2><div class="dl-actions">' +
|
||||
'<a class="btn btn-primary btn-sm" href="' + reportUrl + '" target="_blank" rel="noopener">Download report (PDF)</a>' +
|
||||
'<a class="btn btn-secondary btn-sm" href="' + receiptUrl + '" target="_blank" rel="noopener">Download receipt (PDF)</a>' +
|
||||
'</div></div>';
|
||||
|
||||
$id('resultsBody').innerHTML =
|
||||
verdictHtml + scoreCards + summaryHtml + dimSections + flawsHtml + actionsHtml + consensusHtml + killsHtml + dlHtml;
|
||||
}
|
||||
|
||||
function scoreCard(score, label, blurb) {
|
||||
var s = (typeof score === 'number') ? score : 0;
|
||||
var c = bandFor(s)[2];
|
||||
return '<div class="score-card">' +
|
||||
'<div class="num" style="color:' + c + '">' + s + '<span> / 100</span></div>' +
|
||||
'<div class="lbl">' + esc(label) + '</div>' +
|
||||
'<div class="blurb">' + esc(blurb) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ---------- wiring ----------
|
||||
function init() {
|
||||
initTheme();
|
||||
renderHeader();
|
||||
|
||||
$id('btnTheme').addEventListener('click', toggleTheme);
|
||||
|
||||
// auth tabs
|
||||
var tabs = { login: $id('tabLogin'), register: $id('tabRegister') };
|
||||
Object.keys(tabs).forEach(function (mode) {
|
||||
tabs[mode].addEventListener('click', function () {
|
||||
Object.keys(tabs).forEach(function (m) { tabs[m].classList.toggle('active', m === mode); });
|
||||
$id('loginForm').classList.toggle('hidden', mode !== 'login');
|
||||
$id('registerForm').classList.toggle('hidden', mode !== 'register');
|
||||
setError($id('authError'), null);
|
||||
});
|
||||
});
|
||||
|
||||
$id('loginForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
doLogin($id('loginEmail').value.trim(), $id('loginPassword').value);
|
||||
});
|
||||
$id('registerForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
doRegister($id('regName').value.trim(), $id('regEmail').value.trim(), $id('regPassword').value);
|
||||
});
|
||||
|
||||
$id('btnLogout').addEventListener('click', function () {
|
||||
state.token = null;
|
||||
state.user = null;
|
||||
localStorage.removeItem('vt_token');
|
||||
localStorage.removeItem('vt_user');
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
renderHeader();
|
||||
showView('auth');
|
||||
});
|
||||
|
||||
$id('btnNewReview').addEventListener('click', renderSubmit);
|
||||
$id('btnSubmitBack').addEventListener('click', loadDashboard);
|
||||
$id('btnResultsBack').addEventListener('click', loadDashboard);
|
||||
$id('submitForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
doSubmit(e.target);
|
||||
});
|
||||
|
||||
// initial route
|
||||
if (state.token && state.user) {
|
||||
loadDashboard();
|
||||
} else {
|
||||
showView('auth');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user