← back to Ventura Claw

viewer/public/app.js

165 lines

// VenturaClaw — Live Build Viewer
// Connector tiles, phase tracker, SSE event stream

const PHASES = [
  { id: 0, title: "Phase 0 — Scaffold (Next.js + Tailwind + Drizzle + NextAuth)" },
  { id: 1, title: "Phase 1 — DB schema (users, roles, audits, approvals, …)" },
  { id: 2, title: "Phase 2 — RBAC (admin / user gates)" },
  { id: 3, title: "Phase 3 — Liquid-glass design system" },
  { id: 4, title: "Phase 4 — Mock connector registry" },
  { id: 5, title: "Phase 5 — Core screens + /admin/*" },
  { id: 6, title: "Phase 6 — EXA spec research → connector-specs.json" },
  { id: 7, title: "Phase 7 — Real MCP wiring (Shopify, Stripe, PayPal, Gmail, Slack, CF)" },
  { id: 8, title: "Phase 8 — Approval gate + audit log enforcement" },
  { id: 9, title: "Phase 9 — Smoke tests (Playwright per role)" }
];

// [Display name, category, slug, simple-icons slug or null, hex tint]
const CONNECTORS = [
  ["Shopify","commerce","shopify","shopify","#95BF47"],
  ["Etsy","commerce","etsy","etsy","#F1641E"],
  ["WooCommerce","commerce","woocommerce","woocommerce","#7F54B3"],
  ["Stripe","payments","stripe","stripe","#635BFF"],
  ["PayPal","payments","paypal","paypal","#00457C"],
  ["Shopify Payments","payments","shopify_payments","shopify","#95BF47"],
  ["Square","payments","square","square","#000000"],
  ["Gmail","email","gmail","gmail","#EA4335"],
  ["Google Sheets","email","google_sheets","googlesheets","#34A853"],
  ["Google Drive","email","google_drive","googledrive","#4285F4"],
  ["Outlook","email","outlook","microsoftoutlook","#0078D4"],
  ["Purelymail","email","purelymail",null,"#7C3AED"],
  ["Slack","comms","slack","slack","#4A154B"],
  ["RingCentral","comms","ringcentral","ringcentral","#FF7A00"],
  ["Twilio","comms","twilio","twilio","#F22F46"],
  ["Discord","comms","discord","discord","#5865F2"],
  ["Instagram","social","instagram","instagram","#E4405F"],
  ["Facebook","social","facebook","facebook","#1877F2"],
  ["TikTok","social","tiktok","tiktok","#000000"],
  ["Pinterest","social","pinterest","pinterest","#BD081C"],
  ["LinkedIn","social","linkedin","linkedin","#0A66C2"],
  ["YouTube Shorts","social","youtube_shorts","youtube","#FF0000"],
  ["X / Twitter","social","x_twitter","x","#000000"],
  ["Threads","social","threads","threads","#000000"],
  ["Bluesky","social","bluesky","bluesky","#0085FF"],
  ["Reddit","social","reddit","reddit","#FF4500"],
  ["Mailchimp","marketing","mailchimp","mailchimp","#FFE01B"],
  ["Klaviyo","marketing","klaviyo","klaviyo","#000000"],
  ["Constant Contact","marketing","constant_contact",null,"#1856ED"],
  ["Action Network","marketing","action_network",null,"#EE3424"],
  ["ClickUp","tasks","clickup","clickup","#7B68EE"],
  ["Asana","tasks","asana","asana","#F06A6A"],
  ["Trello","tasks","trello","trello","#0079BF"],
  ["Monday","tasks","monday","mondaydotcom","#FF3D57"],
  ["Notion","tasks","notion","notion","#000000"],
  ["HubSpot","crm","hubspot","hubspot","#FF7A59"],
  ["Salesforce","crm","salesforce","salesforce","#00A1E0"],
  ["Zendesk","support","zendesk","zendesk","#03363D"],
  ["Gorgias","support","gorgias",null,"#1F2937"],
  ["Intercom","support","intercom","intercom","#1F8DED"],
  ["Canva","creative","canva","canva","#00C4CC"],
  ["Figma","creative","figma","figma","#F24E1E"],
  ["Adobe","creative","adobe","adobe","#FF0000"],
  ["Paper","creative","paper",null,"#FF8A00"],
  ["Cloudflare","security","cloudflare","cloudflare","#F38020"],
  ["GoDaddy","domain","godaddy","godaddy","#1BDBDB"],
  ["Vercel","hosting","vercel","vercel","#000000"],
  ["AWS","hosting","aws","amazonaws","#FF9900"],
  ["Sucuri","security","sucuri",null,"#13B26C"],
  ["n8n","automation","n8n","n8n","#EA4B71"],
  ["Zapier","automation","zapier","zapier","#FF4A00"],
  ["Make","automation","make","make","#6D00CC"],
  ["Browserbase","automation","browserbase",null,"#0EA5E9"],
  ["PostgreSQL","data","postgresql","postgresql","#4169E1"],
  ["Supabase","data","supabase","supabase","#3ECF8E"],
  ["Airtable","data","airtable","airtable","#FCB400"]
];

const phaseEl = document.getElementById('phase-list');
const phaseState = new Map();
PHASES.forEach(p => {
  const li = document.createElement('li');
  li.className = 'phase';
  li.id = `phase-${p.id}`;
  li.innerHTML = `<span class="orb orb-pending"></span>
    <span class="phase-title">${p.title}</span>
    <span class="phase-meta">queued</span>`;
  phaseEl.appendChild(li);
  phaseState.set(p.id, 'pending');
});

function logoMarkup(name, simpleSlug, tint) {
  if (simpleSlug) {
    const url = `https://cdn.simpleicons.org/${simpleSlug}/${tint.replace('#','')}`;
    return `<img class="logo" src="${url}" alt="${name}" loading="lazy" onerror="this.outerHTML='<div class=\\'logo logo-fallback\\' style=\\'background:${tint}\\'>${name[0]}</div>'" />`;
  }
  return `<div class="logo logo-fallback" style="background:${tint}">${name[0]}</div>`;
}

const grid = document.getElementById('connector-grid');
CONNECTORS.forEach(([name, cat, slug, simpleSlug, tint]) => {
  const d = document.createElement('div');
  d.className = 'connector-tile';
  d.dataset.connector = slug;
  d.innerHTML = `
    <div class="tile-head">
      ${logoMarkup(name, simpleSlug, tint)}
      <div class="tile-meta">
        <div class="cat">${cat}</div>
        <div class="name">${name}</div>
      </div>
    </div>
    <div class="stat"><span class="orb orb-pending"></span><span class="stat-label">mock pending</span></div>`;
  grid.appendChild(d);
});

const stream = document.getElementById('stream');
const counter = document.getElementById('event-count');
let count = 0;

function setPhase(id, status, meta) {
  const li = document.getElementById(`phase-${id}`);
  if (!li) return;
  li.classList.remove('pending','running','done','blocked');
  li.classList.add(status);
  const orb = li.querySelector('.orb');
  orb.className = `orb orb-${status}`;
  if (meta) li.querySelector('.phase-meta').textContent = meta;
  phaseState.set(id, status);
  refreshOverall();
}

function setConnector(slug, status, label) {
  const tile = document.querySelector(`[data-connector="${slug}"]`);
  if (!tile) return;
  const orb = tile.querySelector('.orb');
  orb.className = `orb orb-${status}`;
  if (label) tile.querySelector('.stat-label').textContent = label;
}

function refreshOverall() {
  const lbl = document.getElementById('overall-label');
  const orb = document.querySelector('#overall-status .orb');
  const states = [...phaseState.values()];
  if (states.every(s => s === 'done')) { lbl.textContent = 'Build complete'; orb.className = 'orb orb-done'; return; }
  if (states.some(s => s === 'blocked')) { lbl.textContent = 'Blocked'; orb.className = 'orb orb-blocked'; return; }
  if (states.some(s => s === 'running')) { lbl.textContent = 'Building…'; orb.className = 'orb orb-running'; return; }
  lbl.textContent = 'Idle'; orb.className = 'orb orb-pending';
}

function pushEvent(ev) {
  count++; counter.textContent = count;
  if (ev.kind === 'phase' && ev.phase != null) setPhase(ev.phase, ev.status, ev.meta || ev.status);
  if (ev.kind === 'connector' && ev.connector) setConnector(ev.connector, ev.status, ev.label);
  const el = document.createElement('div');
  el.className = `event ${ev.level || 'info'}`;
  const t = ev.ts ? new Date(ev.ts).toLocaleTimeString() : new Date().toLocaleTimeString();
  el.innerHTML = `<time>${t}</time><span class="lvl">${ev.level || 'info'}</span><span>${(ev.msg || '').replace(/[<>]/g,'')}</span>`;
  stream.appendChild(el);
  while (stream.childElementCount > 500) stream.removeChild(stream.firstChild);
  stream.scrollTop = stream.scrollHeight;
}

const es = new EventSource('/events');
es.onmessage = (e) => { try { pushEvent(JSON.parse(e.data)); } catch {} };
es.onerror = () => { /* SSE retries automatically */ };