metatroncubeswdev 65a01af4d3
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
Fix critical billing bugs, add local usage caps, publish sales demo
Three launch blockers from the Oct 17 direct-Stripe billing work:

- verifyStripeCheckoutSession defaulted an unconfirmed checkout session
  to "active"/isPaying:true, and had no billing permission check, so an
  abandoned (unpaid) checkout marked the org as a paying subscriber for
  free. Now defaults to "incomplete" and only trusts a real subscription
  status or a confirmed paid payment_status; also pins org id to the
  caller's own organization instead of trusting session metadata.
- The Stripe webhook fell back to parsing an unsigned request body as a
  real event whenever STRIPE_WEBHOOK_SECRET was unset, letting anyone POST
  a fake checkout.session.completed and grant any org a paid plan.
  STRIPE_WEBHOOK_SECRET is now required; unsigned requests are refused.
  Also maps Stripe's subscription.status properly (trialing counted as
  active) instead of collapsing anything but "active" to "canceled".
- meterDataforseoCall and every credit-check in subscription.ts skipped
  all usage metering/capping whenever AUTUMN_SECRET_KEY was unset — which
  is exactly the mode the Oct 17 work introduced for self-host direct
  Stripe billing. Added a local per-org monthly usage ledger
  (usage_ledger table, sqlite + pg) checked against each plan's monthly
  data budget (src/shared/crawlerxPlanBudgets.ts, from the PRD's unit
  economics table) so DataForSEO spend is capped even without Autumn.

Also: documented STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/AUTUMN_SECRET_KEY
in .env.example, and published the CrawlerX product demo as a static page
at /demo.html (self-contained, no backend dependency).

Verified: tsc --noEmit clean, schema-parity test green, full vitest suite
green except two pre-existing unrelated failures (confirmed via stash),
knip/oxlint show no new findings over the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 16:24:38 -04:00

521 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CrawlerX Product Demo</title>
<meta name="claude:viewport-fit-sides" content="cover">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
/* Layout: app shell — guided tour strip on top, left nav of 8 screens, working canvas on the right */
:root{
--bg:#F3F5F7; --surface:#FFFFFF; --sunk:#E9EDF1; --line:#D5DCE3;
--ink:#14202B; --muted:#5A6B7A;
--accent:#0E6E6A; --accent-soft:#D7EDEB; --accent-ink:#FFFFFF;
--mark:#C8811F; --mark-soft:#F8E8CF;
--good:#2E7D4F; --good-soft:#DCEFE3; --warn:#B7791F; --warn-soft:#F6EAD3; --bad:#B4413A; --bad-soft:#F5DEDB;
--f-display:"Bricolage Grotesque", "Segoe UI", system-ui, sans-serif;
--f-body:"IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--f-mono:"IBM Plex Mono", ui-monospace, Consolas, monospace;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#0F161C; --surface:#162029; --sunk:#1D2933; --line:#2C3A46;
--ink:#E6EDF2; --muted:#94A6B5;
--accent:#3FB6AE; --accent-soft:#16383A; --accent-ink:#06201F;
--mark:#E3A44A; --mark-soft:#3A2C17;
--good:#5CC48A; --good-soft:#15301F; --warn:#E0A84B; --warn-soft:#352915; --bad:#E4776F; --bad-soft:#3A1E1C; color-scheme:dark}}
:root[data-theme="dark"]{
--bg:#0F161C; --surface:#162029; --sunk:#1D2933; --line:#2C3A46;
--ink:#E6EDF2; --muted:#94A6B5;
--accent:#3FB6AE; --accent-soft:#16383A; --accent-ink:#06201F;
--mark:#E3A44A; --mark-soft:#3A2C17;
--good:#5CC48A; --good-soft:#15301F; --warn:#E0A84B; --warn-soft:#352915; --bad:#E4776F; --bad-soft:#3A1E1C; color-scheme:dark}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:var(--f-body);font-size:14px;line-height:1.5;margin:0;
padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:16px 40px}
h1,h2,h3{font-family:var(--f-display);text-wrap:balance;margin:0;letter-spacing:-.01em}
h1{font-size:26px}h2{font-size:20px}h3{font-size:15px;font-family:var(--f-body);font-weight:600}
p{margin:0}
.mono,.num{font-family:var(--f-mono);font-variant-numeric:tabular-nums}
.muted{color:var(--muted)}
.wrap{max-width:1180px;margin:0 auto;display:grid;gap:14px}
button{font:inherit;cursor:pointer}
button:focus-visible,[tabindex]:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600}
/* Header */
.top{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px}
.brand{display:flex;align-items:center;gap:10px}
.logo{width:34px;height:34px;border-radius:9px;background:var(--accent);display:grid;place-items:center}
.logo svg{width:20px;height:20px}
.brand small{display:block;color:var(--muted);font-size:12px}
.sample{font-size:12px;background:var(--mark-soft);color:var(--ink);border:1px solid var(--mark);padding:3px 10px;border-radius:999px}
/* Tour strip */
.tour{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:grid;gap:10px}
.tour-head{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;justify-content:space-between}
.tour-text{max-width:78ch}
.tour-text b{font-weight:600}
.dots{display:flex;gap:6px;flex-wrap:wrap}
.dot{width:28px;height:6px;border-radius:3px;background:var(--line);border:0;padding:0}
.dot.on{background:var(--accent)}
.dot.done{background:var(--accent-soft)}
.tour-nav{display:flex;gap:8px}
.btn{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:7px 14px;border-radius:8px;font-weight:500}
.btn:hover{background:var(--sunk)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
.btn.primary:hover{filter:brightness(1.08)}
.btn.mark{background:var(--mark);border-color:var(--mark);color:#1A1205}
.btn:disabled{opacity:.45;cursor:default}
/* App shell */
.app{display:grid;grid-template-columns:210px minmax(0,1fr);background:var(--surface);border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:620px}
.nav{background:var(--sunk);border-right:1px solid var(--line);padding:12px 8px;display:flex;flex-direction:column;gap:2px}
.nav .ws{padding:6px 10px 12px;font-size:12px;color:var(--muted)}
.nav .ws b{display:block;color:var(--ink);font-size:13px}
.nav button{display:flex;align-items:center;gap:10px;text-align:left;border:0;background:transparent;color:var(--ink);padding:8px 10px;border-radius:7px;font-size:13.5px}
.nav button .n{font-family:var(--f-mono);font-size:11px;color:var(--muted);width:16px}
.nav button:hover{background:var(--surface)}
.nav button.on{background:var(--surface);box-shadow:inset 3px 0 0 var(--accent);font-weight:600}
.nav .foot{margin-top:auto;padding:10px;font-size:12px;color:var(--muted)}
.meter{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:4px}
.meter i{display:block;height:100%;background:var(--accent)}
.canvas{padding:20px;display:grid;gap:16px;align-content:start;min-width:0}
.screen-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:10px}
.screen-head p{color:var(--muted);margin-top:4px}
.grid{display:grid;gap:12px}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.panel{border:1px solid var(--line);border-radius:10px;padding:14px;min-width:0;display:grid;gap:10px;align-content:start}
.stat .v{font-family:var(--f-display);font-size:28px;font-weight:700;font-variant-numeric:tabular-nums}
.stat .d{font-size:12px}
.up{color:var(--good)}.down{color:var(--bad)}
.pill{display:inline-block;font-size:11.5px;padding:2px 8px;border-radius:999px;font-weight:500;white-space:nowrap}
.p-good{background:var(--good-soft);color:var(--good)}
.p-warn{background:var(--warn-soft);color:var(--warn)}
.p-bad{background:var(--bad-soft);color:var(--bad)}
.p-acc{background:var(--accent-soft);color:var(--accent)}
.p-mark{background:var(--mark-soft);color:var(--mark)}
.tbl-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;background:var(--sunk)}
tr:last-child td{border-bottom:0}
td.r,th.r{text-align:right}
tbody tr.click{cursor:pointer}
tbody tr.click:hover{background:var(--sunk)}
.health{display:flex;align-items:center;gap:8px}
.hbar{width:70px;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
.hbar i{display:block;height:100%}
.actions{display:grid;gap:8px;counter-reset:a}
.act{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:8px}
.act .k{font-family:var(--f-mono);font-size:12px;width:24px;height:24px;border-radius:6px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-weight:500}
.act small{display:block;color:var(--muted)}
/* Onboarding */
.steps-on{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
.field{display:grid;gap:4px}
.field label{font-size:12px;color:var(--muted);font-weight:500}
.field input,.field select,textarea{font:inherit;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:8px;padding:8px 10px;width:100%}
textarea{font-family:var(--f-mono);font-size:12.5px;min-height:150px;resize:vertical}
.check{display:flex;gap:8px;align-items:center;font-size:13px}
.crawl-log{font-family:var(--f-mono);font-size:12px;background:var(--sunk);border-radius:8px;padding:10px;min-height:150px;display:grid;gap:2px;align-content:start}
/* Audit + fix */
.split{display:grid;grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr);gap:12px}
.issue{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--surface);text-align:left;color:var(--ink);width:100%}
.issue:hover{background:var(--sunk)}
.issue.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
.issue.fixed{opacity:.6}
.sev{width:8px;height:32px;border-radius:4px}
.code{font-family:var(--f-mono);font-size:12px;background:var(--sunk);border-radius:8px;padding:10px;white-space:pre-wrap;word-break:break-word;overflow-x:auto}
.code.new{background:var(--accent-soft)}
.code del{color:var(--bad);text-decoration:line-through}
.prog{display:grid;gap:6px}
.pstep{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--muted)}
.pstep .b{width:16px;height:16px;border-radius:50%;border:2px solid var(--line);flex:none}
.pstep.go{color:var(--ink)}.pstep.go .b{border-color:var(--mark);border-top-color:transparent;animation:spin .8s linear infinite}
.pstep.ok{color:var(--ink)}.pstep.ok .b{border-color:var(--good);background:var(--good)}
@keyframes spin{to{transform:rotate(360deg)}}
.score{display:flex;align-items:center;gap:14px}
.ring{width:84px;height:84px}
/* Keywords */
.tabs{display:flex;gap:4px;flex-wrap:wrap}
.tab{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:6px 12px;border-radius:999px;font-size:13px}
.tab.on{background:var(--ink);color:var(--surface);border-color:var(--ink)}
.kd{display:inline-flex;align-items:center;gap:6px}
.kd i{width:9px;height:9px;border-radius:50%}
/* Geo grid */
.geo{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;max-width:330px;aspect-ratio:1;width:100%;position:relative}
.geo span{display:grid;place-items:center;border-radius:50%;font-family:var(--f-mono);font-size:12px;font-weight:500}
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px;vertical-align:-1px}
.spark{width:100%;height:120px}
/* AI vis */
.yes{color:var(--good);font-weight:600}.no{color:var(--muted)}
.share{display:grid;gap:8px}
.srow{display:grid;grid-template-columns:150px minmax(0,1fr) 44px;gap:10px;align-items:center;font-size:13px}
.sbar{height:10px;background:var(--sunk);border-radius:5px;overflow:hidden}
.sbar i{display:block;height:100%;background:var(--muted)}
.sbar i.me{background:var(--accent)}
/* Report */
.report{border:1px solid var(--line);border-radius:10px;overflow:hidden}
.rhead{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 16px;background:var(--sunk);flex-wrap:wrap}
.agency-logo{font-family:var(--f-display);font-weight:700;font-size:17px}
.rbody{padding:16px;display:grid;gap:14px}
.phone{border:1px solid var(--line);border-radius:22px;padding:12px;background:var(--sunk);max-width:300px;display:grid;gap:8px}
.bubble{background:var(--surface);border-radius:10px;padding:10px;font-size:13px;border:1px solid var(--line)}
.bubble .t{font-size:11px;color:var(--muted);text-align:right}
/* Pricing */
.plans{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
.plan{border:1px solid var(--line);border-radius:10px;padding:14px;display:grid;gap:8px;align-content:start}
.plan.feat{border:2px solid var(--accent)}
.plan .price{font-family:var(--f-display);font-size:26px;font-weight:700}
.plan ul{margin:0;padding-left:18px;display:grid;gap:3px;font-size:13px}
.toggle{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden}
.toggle button{border:0;background:transparent;color:var(--ink);padding:6px 14px;font-size:13px}
.toggle button.on{background:var(--accent);color:var(--accent-ink)}
.ck{color:var(--good);font-weight:600}.x{color:var(--bad)}
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(20px + env(safe-area-inset-bottom,0px));background:var(--ink);color:var(--surface);padding:10px 16px;border-radius:8px;font-size:13px;z-index:20;max-width:calc(100% - 32px)}
@media (max-width:900px){
.app{grid-template-columns:1fr}
.nav{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line)}
.nav .ws,.nav .foot{display:none}
.nav button{white-space:nowrap}
.g4,.plans{grid-template-columns:repeat(2,minmax(0,1fr))}
.split,.g2,.g3,.steps-on{grid-template-columns:1fr}
}
@media (max-width:480px){.g4,.plans{grid-template-columns:1fr}.canvas{padding:14px}.srow{grid-template-columns:110px minmax(0,1fr) 40px}}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">
<div class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21M7.5 10.5h6M10.5 7.5v6"/></svg></div>
<div><h1>CrawlerX</h1><small>Find it. Fix it. Prove it. SEO for 10 sites at ₹2,499/month.</small></div>
</div>
<span class="sample">Interactive demo · all sites and numbers are sample data</span>
</header>
<section class="tour" aria-live="polite">
<div class="tour-head">
<span class="label" id="tourStep">Step 1 of 8</span>
<div class="dots" id="dots"></div>
</div>
<p class="tour-text" id="tourText"></p>
<div class="tour-nav">
<button class="btn" id="prev">Back</button>
<button class="btn primary" id="next">Next step</button>
</div>
</section>
<div class="app">
<nav class="nav" id="nav" aria-label="Product screens">
<div class="ws"><b>Pixelkraft Digital</b>Agency workspace · Pro plan</div>
</nav>
<main class="canvas" id="canvas"></main>
</div>
</div>
<div class="toast" id="toast" hidden></div>
<script>
const $ = s => document.querySelector(s);
const SCREENS = [
{id:'onboard', name:'Get started',
tour:'<b>Onboarding, under 5 minutes.</b> An agency signs up with Google or a phone OTP, pastes its client sites (or imports them from Search Console), and CrawlerX starts crawling all 10 at once. Press <b>Start crawl</b> to watch it.'},
{id:'dash', name:'Portfolio',
tour:'<b>One screen for every client site.</b> Health score, traffic from Google Search Console, tracked ranks and AI mentions per site, plus this week\'s <b>Next 5 actions</b> ranked by impact. Click a site or an action to open its audit.'},
{id:'audit', name:'Audit & fix',
tour:'<b>The core difference: we fix, not just find.</b> Pick an issue, read the AI-written fix, then <b>Approve and push</b>. CrawlerX writes it to WordPress or Shopify, re-crawls the page and confirms the fix is live. Watch the health score move.'},
{id:'kw', name:'Keywords',
tour:'<b>Keyword research built for India.</b> Volumes by city, intent and difficulty, in English and regional languages. Switch the language tabs, then add keywords to rank tracking in one click.'},
{id:'rank', name:'Ranks & local',
tour:'<b>Rank tracking plus a local map.</b> Weekly Google positions with a trend line, and a 7×7 geo-grid showing where the clinic ranks block by block across the city, the view Local Falcon and Synup sell separately.'},
{id:'ai', name:'AI visibility',
tour:'<b>Does ChatGPT recommend you?</b> CrawlerX asks the AI engines the questions customers ask, logs who gets mentioned and cited, and shows your share against competitors. Semrush sells this as a separate $199+ plan.'},
{id:'report', name:'Client report',
tour:'<b>White-label reporting.</b> The agency\'s own logo, a plain-language summary of what was fixed and what moved, sent as a PDF and a WhatsApp message every Monday.'},
{id:'price', name:'Plans',
tour:'<b>Priced for Indian agencies.</b> 10 sites for ₹2,499 a month with UPI Autopay and GST invoices, against $139.95 for one Semrush Pro seat. Toggle the currency and compare.'}
];
let cur = 0;
const nav = $('#nav');
SCREENS.forEach((s,i)=>{
const b=document.createElement('button');b.id='nav-'+s.id;b.innerHTML=`<span class="n">${i+1}</span>${s.name}`;
b.onclick=()=>go(i);nav.appendChild(b);
});
const foot=document.createElement('div');foot.className='foot';
foot.innerHTML='Usage this month<div class="meter"><i style="width:38%"></i></div><span class="num">171 / 450</span> keyword lookups';
nav.appendChild(foot);
const dots=$('#dots');
SCREENS.forEach((s,i)=>{const d=document.createElement('button');d.className='dot';d.setAttribute('aria-label','Go to step '+(i+1)+': '+s.name);d.onclick=()=>go(i);dots.appendChild(d)});
$('#prev').onclick=()=>go(cur-1);$('#next').onclick=()=>go(cur+1);
function toast(t){const el=$('#toast');el.textContent=t;el.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(()=>el.hidden=true,2600)}
/* ---------- sample data ---------- */
const SITES=[
{d:'smileline-dental.in',c:'Pune',h:64,t:'+12%',k:'38/120',ai:'2 of 12',st:'warn'},
{d:'annapoorna-sweets.com',c:'Coimbatore',h:81,t:'+4%',k:'51/90',ai:'5 of 12',st:'good'},
{d:'zenyoga-studio.in',c:'Bengaluru',h:72,t:'+9%',k:'22/60',ai:'1 of 10',st:'warn'},
{d:'kraftleather.co',c:'Kanpur',h:49,t:'−6%',k:'14/80',ai:'0 of 10',st:'bad'},
{d:'brightpath-tuitions.in',c:'Jaipur',h:77,t:'+15%',k:'30/45',ai:'3 of 8',st:'good'},
{d:'greenleaf-organics.in',c:'Hyderabad',h:68,t:'+2%',k:'19/70',ai:'2 of 10',st:'warn'},
{d:'metroautoparts.in',c:'Chennai',h:58,t:'−3%',k:'27/110',ai:'1 of 12',st:'bad'},
{d:'lotus-ayurveda.in',c:'Kochi',h:85,t:'+7%',k:'44/60',ai:'6 of 10',st:'good'},
{d:'urbannest-realty.in',c:'Gurugram',h:71,t:'+1%',k:'16/50',ai:'1 of 8',st:'warn'},
{d:'chaicraft-cafe.in',c:'Indore',h:79,t:'+11%',k:'12/25',ai:'2 of 6',st:'good'}
];
const ISSUES=[
{id:'i1',sev:'bad',title:'Missing LocalBusiness schema',pages:'Homepage',impact:'High',why:'Google and AI engines can\'t read your address, hours and phone as structured facts. Clinics with schema are cited more often in AI answers.',
before:'\x3C!-- no structured data on page -->',
after:'\x3Cscript type="application/ld+json">\n{\n "@context": "https://schema.org",\n "@type": "Dentist",\n "name": "SmileLine Dental Clinic",\n "address": {\n "@type": "PostalAddress",\n "streetAddress": "12 FC Road, Shivajinagar",\n "addressLocality": "Pune", "postalCode": "411005",\n "addressCountry": "IN"\n },\n "telephone": "+91-20-0000-0000",\n "openingHours": "Mo-Sa 10:00-20:00"\n}\n\x3C/script>',gain:6},
{id:'i2',sev:'bad',title:'Title tags too long on 14 pages',pages:'14 service pages',impact:'High',why:'Titles over 60 characters get cut in results. The fix keeps the main keyword and city in the first 50 characters.',
before:'<title><del>Welcome to SmileLine Dental Clinic | Best Dentist | Root Canal Treatment | Teeth Whitening | Pune Maharashtra</del></title>',
after:'<title>Root Canal Treatment in Pune | SmileLine Dental</title>',gain:5},
{id:'i3',sev:'warn',title:'38 images without alt text',pages:'Gallery, Services',impact:'Medium',why:'Alt text helps image search and accessibility. The AI describes each image from its content and file context.',
before:'<img src="/img/IMG_4471.jpg">',
after:'<img src="/img/IMG_4471.jpg"\n alt="Dentist explaining a dental X-ray to a patient at SmileLine, Pune">',gain:3},
{id:'i4',sev:'warn',title:'Slow mobile LCP (4.1 s)',pages:'Homepage',impact:'Medium',why:'The hero image is a 2.4 MB JPEG. Serving WebP at the right size brings LCP under 2.5 s.',
before:'<img src="/hero.jpg" width="2400">',
after:'<img src="/hero-1200.webp" width="1200" height="640"\n fetchpriority="high" alt="SmileLine clinic reception">',gain:4},
{id:'i5',sev:'warn',title:'No FAQ block on service pages',pages:'6 service pages',impact:'Medium',why:'Question-and-answer content is what AI engines quote. The draft answers the 4 questions patients search most for each service.',
before:'\x3C!-- page ends after pricing table -->',
after:'<section class="faq">\n <h2>Root canal FAQs</h2>\n <h3>Is a root canal painful?</h3>\n <p>With local anaesthesia most patients feel pressure,\n not pain. Treatment takes 60–90 minutes.</p>\n ...\n</section>',gain:3}
];
let health=64; const fixed=new Set(); let sel='i1';
const KW={
en:[['dentist near me','Pune','40,500','Local',38,'good'],['root canal treatment cost pune','Pune','2,900','Commercial',22,'good'],['teeth whitening pune','Pune','1,600','Commercial',31,'good'],['best dental clinic in pune','Pune','3,600','Local',57,'warn'],['dental implants price','India','14,800','Commercial',64,'bad'],['braces for adults','India','6,600','Info',45,'warn']],
hi:[['दांत का डॉक्टर','India','9,900','Local',18,'good'],['दांत दर्द का इलाज','India','22,200','Info',26,'good'],['रूट कैनाल क्या है','India','4,400','Info',12,'good'],['दांतों की सफाई खर्च','India','1,300','Commercial',15,'good']],
ta:[['பல் மருத்துவர்','Tamil Nadu','6,600','Local',14,'good'],['பல் வலி தீர்வு','Tamil Nadu','3,600','Info',11,'good'],['பல் கிளினிக் அருகில்','Chennai','1,900','Local',20,'good']]
};
let kwLang='en';
/* ---------- screens ---------- */
const R={};
R.onboard=()=>`
<div class="screen-head"><div><h2>Add your client sites</h2><p>Paste up to 10 domains, one per line, or import them from Google Search Console.</p></div></div>
<div class="steps-on">
<div class="panel"><span class="label">1 · Account</span>
<div class="field"><label for="ob-name">Agency name</label><input id="ob-name" value="Pixelkraft Digital"></div>
<div class="field"><label for="ob-phone">Mobile (OTP login)</label><input id="ob-phone" value="+91 98xxx xxx21"></div>
<div class="field"><label for="ob-lang">Interface language</label><select id="ob-lang"><option>English</option><option>हिन्दी</option><option>தமிழ்</option></select></div>
</div>
<div class="panel"><span class="label">2 · Sites</span>
<textarea id="ob-sites" aria-label="Site list">${SITES.map(s=>s.d).join('\n')}</textarea>
<label class="check"><input type="checkbox" id="ob-gsc" checked> Connect Google Search Console for real clicks</label>
<label class="check"><input type="checkbox" id="ob-gbp" checked> Connect Google Business Profiles</label>
</div>
<div class="panel"><span class="label">3 · First crawl</span>
<div class="crawl-log" id="log"><span class="muted">Waiting to start…</span></div>
<button class="btn primary" id="startCrawl">Start crawl</button>
</div>
</div>`;
R.dash=()=>{
const avg=Math.round(SITES.reduce((a,s)=>a+(s.d==='smileline-dental.in'?health:s.h),0)/SITES.length);
return `
<div class="screen-head"><div><h2>Portfolio · 10 sites</h2><p>Week of 12 Oct 2026 · data from your crawls, Search Console and tracked SERPs</p></div><span class="pill p-acc">Next crawl: Mon 19 Oct, 03:00 IST</span></div>
<div class="grid g4">
<div class="panel stat"><span class="label">Avg health score</span><span class="v num">${avg}</span><span class="d up">+5 since last month</span></div>
<div class="panel stat"><span class="label">Clicks (GSC, 28 days)</span><span class="v num">48,210</span><span class="d up">+8.4% vs previous 28 days</span></div>
<div class="panel stat"><span class="label">Keywords in top 10</span><span class="v num">273</span><span class="d">of 300 tracked · <span class="up">+19</span></span></div>
<div class="panel stat"><span class="label">AI answers mentioning you</span><span class="v num">23</span><span class="d">of 96 tracked prompts</span></div>
</div>
<div class="grid" style="grid-template-columns:minmax(0,1.5fr) minmax(0,1fr)">
<div class="tbl-wrap"><table><thead><tr><th>Site</th><th>City</th><th>Health</th><th class="r">Clicks</th><th class="r">Top 10</th><th class="r">AI mentions</th></tr></thead><tbody>
${SITES.map(s=>{const h=s.d==='smileline-dental.in'?health:s.h;const st=h>=75?'good':h>=60?'warn':'bad';return `<tr class="click" tabindex="0" data-site="${s.d}"><td>${s.d}</td><td class="muted">${s.c}</td><td><span class="health"><span class="hbar"><i style="width:${h}%;background:var(--${st})"></i></span><span class="num">${h}</span></span></td><td class="r num ${s.t.startsWith('+')?'up':'down'}">${s.t}</td><td class="r num">${s.k}</td><td class="r num">${s.ai}</td></tr>`}).join('')}
</tbody></table></div>
<div class="panel"><h3>Next 5 actions this week</h3><p class="muted" style="font-size:12.5px">Ranked by expected traffic gain. Each one opens with the fix already written.</p>
<div class="actions">
${[['Add LocalBusiness schema','smileline-dental.in · homepage','High','p-bad'],['Shorten 14 title tags','smileline-dental.in','High','p-bad'],['Fix 22 broken internal links','kraftleather.co','High','p-bad'],['Answer “best leather bags kanpur” in an FAQ','kraftleather.co · AI gap','Med','p-warn'],['Compress hero image (LCP 4.1 s)','metroautoparts.in','Med','p-warn']].map((a,i)=>`<div class="act"><span class="k">${i+1}</span><div>${a[0]}<small>${a[1]}</small></div><span class="pill ${a[3]}">${a[2]}</span></div>`).join('')}
</div>
<button class="btn primary" id="openAudit">Open fixes for smileline-dental.in</button>
</div>
</div>`};
function ring(v){const c=2*Math.PI*34,st=v>=75?'good':v>=60?'warn':'bad';return `<svg class="ring" viewBox="0 0 84 84" role="img" aria-label="Health score ${v} of 100"><circle cx="42" cy="42" r="34" fill="none" stroke="var(--line)" stroke-width="8"/><circle cx="42" cy="42" r="34" fill="none" stroke="var(--${st})" stroke-width="8" stroke-linecap="round" stroke-dasharray="${c*v/100} ${c}" transform="rotate(-90 42 42)"/><text x="42" y="48" text-anchor="middle" font-family="Bricolage Grotesque, sans-serif" font-size="22" font-weight="700" fill="var(--ink)">${v}</text></svg>`}
R.audit=()=>{
const it=ISSUES.find(x=>x.id===sel);const done=fixed.has(it.id);
return `
<div class="screen-head"><div><h2>smileline-dental.in · Audit</h2><p>Crawled 412 pages · 128 checks · last crawl 14 Oct 2026, 03:12 IST</p></div>
<div class="score">${ring(health)}<div><span class="label">Health score</span><p class="muted" style="font-size:12.5px">${fixed.size} of ${ISSUES.length} top issues fixed</p></div></div></div>
<div class="split">
<div class="grid" style="align-content:start">
<span class="label">Top issues by impact</span>
${ISSUES.map(x=>`<button class="issue ${x.id===sel?'on':''} ${fixed.has(x.id)?'fixed':''}" data-issue="${x.id}"><span class="sev" style="background:var(--${fixed.has(x.id)?'good':x.sev})"></span><span><b style="font-weight:600">${x.title}</b><br><small class="muted">${x.pages}</small></span>${fixed.has(x.id)?'<span class="pill p-good">Fixed</span>':`<span class="pill ${x.sev==='bad'?'p-bad':'p-warn'}">${x.impact}</span>`}</button>`).join('')}
<p class="muted" style="font-size:12.5px">+ 46 lower-impact issues (redirect chains, missing H1 on 3 pages, mixed content)</p>
</div>
<div class="panel">
<h3>${it.title}</h3>
<p>${it.why}</p>
<span class="label">Now on the site</span><div class="code">${it.before.replace(/<(?!\/?del>)/g,'&lt;')}</div>
<span class="label">AI-generated fix <span class="pill p-acc" style="text-transform:none;letter-spacing:0">editable</span></span><div class="code new" contenteditable="true" spellcheck="false" aria-label="Generated fix, editable">${it.after.replace(/</g,'&lt;')}</div>
<div class="prog" id="prog" ${done?'':'hidden'}>
<div class="pstep ${done?'ok':''}" id="ps1"><span class="b"></span>Pushing to WordPress (via CrawlerX plugin)</div>
<div class="pstep ${done?'ok':''}" id="ps2"><span class="b"></span>Re-crawling affected pages</div>
<div class="pstep ${done?'ok':''}" id="ps3"><span class="b"></span>Verified live · Google re-index requested via Search Console</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn mark" id="approve" ${done?'disabled':''}>${done?'Fix is live':'Approve and push to WordPress'}</button>
<button class="btn" id="copyFix">Copy code</button>
</div>
</div>
</div>`};
R.kw=()=>`
<div class="screen-head"><div><h2>Keyword research</h2><p>Seed: <b>dentist</b> · location: Pune, Maharashtra · Google India</p></div>
<div class="tabs" role="tablist">${[['en','English'],['hi','हिन्दी'],['ta','தமிழ்']].map(([k,l])=>`<button class="tab ${kwLang===k?'on':''}" data-lang="${k}" role="tab" aria-selected="${kwLang===k}">${l}</button>`).join('')}</div></div>
<div class="tbl-wrap"><table><thead><tr><th>Keyword</th><th>Location</th><th class="r">Monthly searches</th><th>Intent</th><th>Difficulty</th><th></th></tr></thead><tbody>
${KW[kwLang].map((r,i)=>`<tr><td>${r[0]}</td><td class="muted">${r[1]}</td><td class="r num">${r[2]}</td><td><span class="pill p-acc">${r[3]}</span></td><td><span class="kd"><i style="background:var(--${r[5]})"></i><span class="num">${r[4]}</span>/100</span></td><td class="r"><button class="btn" data-track="${i}" style="padding:4px 10px;font-size:12.5px">Track</button></td></tr>`).join('')}
</tbody></table></div>
<div class="grid g3">
<div class="panel"><span class="label">Quick win</span><p><b>Regional-language demand is untapped.</b> Hindi “दांत दर्द का इलाज” gets 22,200 searches with difficulty 26. No Pune clinic has a Hindi page for it.</p></div>
<div class="panel"><span class="label">Content brief</span><p>One click turns a keyword into a brief: headings, questions to answer, word count and internal links.</p><button class="btn" id="brief">Generate brief</button></div>
<div class="panel"><span class="label">Data note</span><p class="muted">Volumes are monthly averages from Google Ads data via our data provider, cached and refreshed every 30 days.</p></div>
</div>`;
function geo(){const g=[];const center=3;for(let r=0;r<7;r++)for(let c=0;c<7;c++){const d=Math.hypot(r-center,c-center*1.1+.6);let v=Math.max(1,Math.round(1+d*2.3+((r*7+c)%3)));if(v>20)v='20+';g.push(v)}return g}
R.rank=()=>{const g=geo();const col=v=>v==='20+'||v>10?'bad':v>3?'warn':'good';
const pts=[18,17,15,15,13,12,11,9,8,7,6,4];const w=520,h=120,x=i=>20+i*(w-40)/(pts.length-1),y=v=>10+(v-1)/19*(h-30);
return `
<div class="screen-head"><div><h2>Ranks &amp; local · smileline-dental.in</h2><p>Keyword: <b>dentist near me</b> · tracked weekly · Google mobile</p></div><span class="pill p-good">Position 4 · up 14 in 12 weeks</span></div>
<div class="grid g2">
<div class="panel"><h3>Local geo-grid · 7 × 7 points, 0.5 km apart</h3>
<div class="geo" role="img" aria-label="Rank by map location; ranks 1 to 3 near the clinic, falling to 20+ at the edges">${g.map(v=>`<span style="background:var(--${col(v)}-soft);color:var(--${col(v)})">${v}</span>`).join('')}</div>
<div class="legend"><span><i style="background:var(--good)"></i>Rank 1–3</span><span><i style="background:var(--warn)"></i>4–10</span><span><i style="background:var(--bad)"></i>11+</span></div>
<p class="muted" style="font-size:12.5px">Share of local visibility: <b class="num">41%</b>. The clinic owns the map within about 1 km of FC Road and loses to two competitors in Kothrud.</p>
</div>
<div class="panel"><h3>Position over 12 weeks</h3>
<svg class="spark" viewBox="0 0 ${w} ${h}" role="img" aria-label="Position improved from 18 to 4 over 12 weeks" preserveAspectRatio="none">
${[1,10,20].map(v=>`<line x1="20" x2="${w-20}" y1="${y(v)}" y2="${y(v)}" stroke="var(--line)"/><text x="0" y="${y(v)+4}" font-size="10" fill="var(--muted)" font-family="IBM Plex Mono, monospace">${v}</text>`).join('')}
<polyline fill="none" stroke="var(--accent)" stroke-width="2.5" points="${pts.map((v,i)=>x(i)+','+y(v)).join(' ')}"/>
<circle cx="${x(pts.length-1)}" cy="${y(4)}" r="5" fill="var(--accent)"/>
</svg>
<div class="tbl-wrap"><table><thead><tr><th>Keyword</th><th class="r">Pos.</th><th class="r">Change</th><th>SERP feature</th></tr></thead><tbody>
<tr><td>dentist near me</td><td class="r num">4</td><td class="r num up">+14</td><td>Map pack</td></tr>
<tr><td>root canal treatment cost pune</td><td class="r num">2</td><td class="r num up">+5</td><td>AI Overview cited</td></tr>
<tr><td>teeth whitening pune</td><td class="r num">7</td><td class="r num up">+3</td><td>People also ask</td></tr>
<tr><td>dental implants price</td><td class="r num">23</td><td class="r num down">−2</td><td>—</td></tr>
</tbody></table></div>
</div>
</div>`};
R.ai=()=>{
const P=[['best dentist in pune for root canal',1,1,0,1],['painless dental clinic shivajinagar',1,0,1,1],['how much does teeth whitening cost in pune',0,1,0,1],['dentist open on sunday pune',0,0,0,0],['affordable braces pune for adults',0,0,1,0]];
const mk=v=>v?'<span class="yes">Mentioned</span>':'<span class="no">—</span>';
const S=[['SmileLine Dental (you)',42,1],['Clove Dental',58,0],['Sabka Dentist',35,0],['Dr. Mehta\'s Clinic',21,0]];
return `
<div class="screen-head"><div><h2>AI visibility · smileline-dental.in</h2><p>12 tracked prompts · checked weekly in ChatGPT, Gemini, Perplexity and Google AI Overviews</p></div><span class="pill p-acc">Score 42 / 100 · up from 17</span></div>
<div class="tbl-wrap"><table><thead><tr><th>Prompt customers ask</th><th>ChatGPT</th><th>Gemini</th><th>Perplexity</th><th>AI Overview</th></tr></thead><tbody>
${P.map(p=>`<tr><td>${p[0]}</td><td>${mk(p[1])}</td><td>${mk(p[2])}</td><td>${mk(p[3])}</td><td>${mk(p[4])}</td></tr>`).join('')}
</tbody></table></div>
<div class="grid g2">
<div class="panel"><h3>Share of AI mentions vs competitors</h3><div class="share">
${S.map(s=>`<div class="srow"><span>${s[0]}</span><span class="sbar"><i class="${s[2]?'me':''}" style="width:${s[1]}%"></i></span><span class="num r">${s[1]}%</span></div>`).join('')}
</div><p class="muted" style="font-size:12px">Share of the 48 prompt-engine checks this week that named each clinic.</p></div>
<div class="panel"><h3>Why the AI skipped you on “dentist open on sunday pune”</h3>
<div class="actions">
<div class="act"><span class="k">1</span><div>Sunday hours are only in an image<small>Add opening hours as text and in schema</small></div><span class="pill p-bad">High</span></div>
<div class="act"><span class="k">2</span><div>No mention on Practo or Justdial listing<small>AI engines cite these directories for clinics</small></div><span class="pill p-warn">Med</span></div>
<div class="act"><span class="k">3</span><div>robots.txt blocks GPTBot<small>Allow AI crawlers to read the site</small></div><span class="pill p-warn">Med</span></div>
</div>
<button class="btn primary" id="aiFix">Send these to the fix queue</button>
</div>
</div>`};
R.report=()=>`
<div class="screen-head"><div><h2>Client report · October 2026</h2><p>Sent under your agency's brand. Clients never see CrawlerX.</p></div><button class="btn primary" id="send">Schedule for every Monday</button></div>
<div class="grid" style="grid-template-columns:minmax(0,1.6fr) minmax(0,1fr)">
<div class="report">
<div class="rhead"><span class="agency-logo">Pixelkraft Digital</span><span class="muted">Monthly SEO report · SmileLine Dental Clinic, Pune</span></div>
<div class="rbody">
<p><b>Summary in plain words.</b> Google clicks grew 12% this month. You now appear 4th for “dentist near me” across most of Shivajinagar, and ChatGPT recommends SmileLine for root canal questions in Pune.</p>
<div class="grid g3">
<div class="panel stat"><span class="label">Website visits from Google</span><span class="v num">3,912</span><span class="d up">+12%</span></div>
<div class="panel stat"><span class="label">Calls from Google Maps</span><span class="v num">184</span><span class="d up">+21%</span></div>
<div class="panel stat"><span class="label">Health score</span><span class="v num">${health}</span><span class="d up">from 64</span></div>
</div>
<div><span class="label">What we fixed</span><ul style="margin:6px 0 0;padding-left:18px;display:grid;gap:3px"><li>Added clinic details as structured data so Google and AI read them correctly</li><li>Rewrote 14 page titles to show the service and “Pune” first</li><li>Described 38 images for image search</li></ul></div>
<div><span class="label">Next month</span><p>Hindi page for “दांत दर्द का इलाज”, Sunday hours in text, Practo listing update.</p></div>
</div>
</div>
<div class="phone" aria-label="WhatsApp preview">
<span class="label">WhatsApp preview</span>
<div class="bubble"><b>Pixelkraft Digital</b><br>Hi Dr. Kulkarni, your October SEO report is ready.<br>• Google visits +12%<br>• Maps calls +21%<br>• Now #4 for “dentist near me”<br>Full PDF attached.<div class="t">Mon 09:00</div></div>
<div class="bubble">📄 SmileLine_SEO_Oct2026.pdf · 6 pages<div class="t">Mon 09:00</div></div>
</div>
</div>`;
let cur$='inr';
R.price=()=>{
const p={inr:['₹0','₹999','₹2,499','₹7,499'],usd:['$0','$15','$29','$79']}[cur$];
return `
<div class="screen-head"><div><h2>Plans</h2><p>All modules on every paid plan. Plans differ only by how many sites and how much data. Prices exclude 18% GST.</p></div>
<div class="toggle" role="group" aria-label="Currency"><button class="${cur$==='inr'?'on':''}" data-cur="inr">₹ INR</button><button class="${cur$==='usd'?'on':''}" data-cur="usd">$ USD</button></div></div>
<div class="plans">
<div class="plan"><h3>Free</h3><span class="price">${p[0]}</span><ul><li>1 site, 100-page audit</li><li>10 tracked keywords</li><li>30 keyword lookups/month</li></ul></div>
<div class="plan"><h3>Starter</h3><span class="price">${p[1]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li>3 sites, 1,000 pages each</li><li>100 tracked keywords</li><li>150 lookups, 40 AI checks</li><li>AI fixes + push</li></ul></div>
<div class="plan feat"><span class="pill p-acc" style="justify-self:start">Most agencies pick this</span><h3>Pro</h3><span class="price">${p[2]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li><b>10 sites</b>, 3,000 pages each</li><li>300 tracked keywords + geo-grid</li><li>450 lookups, 120 AI checks</li><li>White-label PDF + WhatsApp reports</li></ul></div>
<div class="plan"><h3>Agency</h3><span class="price">${p[3]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li>30 sites, 5,000 pages each</li><li>1,000 tracked keywords</li><li>Client portal, 5 seats</li><li>API + MCP access</li></ul></div>
</div>
<div class="tbl-wrap"><table><thead><tr><th>Compare at the 10-site level</th><th>CrawlerX Pro</th><th>Semrush Pro</th><th>Ubersuggest Individual</th><th>SE Ranking Essential</th></tr></thead><tbody>
<tr><td>Price per month</td><td><b>${cur$==='inr'?'₹2,499':'$29'}</b></td><td>$139.95</td><td>$29</td><td>$59</td></tr>
<tr><td>Sites / projects included</td><td>10</td><td>5 projects</td><td>limits are per domain</td><td>5 projects</td></tr>
<tr><td>Writes and pushes the fix</td><td class="ck">Yes</td><td class="x">No</td><td class="x">No</td><td class="x">No</td></tr>
<tr><td>AI search visibility</td><td class="ck">Included</td><td>Semrush One, from $199</td><td class="x">No</td><td>Add-on</td></tr>
<tr><td>Local geo-grid</td><td class="ck">Included</td><td>Local add-on</td><td class="x">No</td><td>Add-on</td></tr>
<tr><td>White-label reports</td><td class="ck">Included</td><td>Business plan only</td><td class="x">No</td><td>Higher plans</td></tr>
<tr><td>INR billing, UPI Autopay, GST invoice</td><td class="ck">Yes</td><td>INR billing</td><td class="x">No</td><td class="x">No</td></tr>
<tr><td>Hindi / Tamil interface and keywords</td><td class="ck">Yes</td><td>Keywords only</td><td>Keywords only</td><td>Keywords only</td></tr>
</tbody></table></div>
<p class="muted" style="font-size:12px">Competitor prices are published list prices as of 2026 from public pricing pages and reviews; limits vary by plan and change often.</p>`};
/* ---------- wiring ---------- */
function bind(id){
if(id==='onboard'){$('#startCrawl').onclick=runCrawl}
if(id==='dash'){document.querySelectorAll('[data-site]').forEach(r=>{const f=()=>{if(r.dataset.site==='smileline-dental.in')go(2);else toast('In the full product this opens '+r.dataset.site+'. The demo follows smileline-dental.in.')};r.onclick=f;r.onkeydown=e=>{if(e.key==='Enter')f()}});$('#openAudit').onclick=()=>go(2)}
if(id==='audit'){
document.querySelectorAll('[data-issue]').forEach(b=>b.onclick=()=>{sel=b.dataset.issue;render()});
$('#approve').onclick=approve;
$('#copyFix').onclick=()=>{const t=document.querySelector('.code.new').innerText;const ok=()=>toast('Fix copied');try{navigator.clipboard.writeText(t).then(ok,()=>toast('Select the code and copy it manually'))}catch(e){toast('Select the code and copy it manually')}};
}
if(id==='kw'){document.querySelectorAll('[data-lang]').forEach(b=>b.onclick=()=>{kwLang=b.dataset.lang;render()});document.querySelectorAll('[data-track]').forEach(b=>b.onclick=()=>{b.textContent='Tracking';b.disabled=true;toast('Added to rank tracking · 299 of 300 used')});$('#brief').onclick=()=>toast('Brief drafted: 7 headings, 6 questions, ~1,400 words')}
if(id==='ai'){$('#aiFix').onclick=()=>toast('3 fixes added to the queue for smileline-dental.in')}
if(id==='report'){$('#send').onclick=()=>toast('Scheduled: PDF + WhatsApp every Monday 09:00 IST')}
if(id==='price'){document.querySelectorAll('[data-cur]').forEach(b=>b.onclick=()=>{cur$=b.dataset.cur;render()})}
}
function runCrawl(){
const log=$('#log'),btn=$('#startCrawl');btn.disabled=true;log.innerHTML='';
const lines=['Fetching robots.txt and sitemaps for 10 sites…','Rendering JavaScript pages with headless Chrome…',...SITES.slice(0,6).map(s=>`✓ ${s.d} — ${200+Math.round(s.h*4)} pages`),'… 4 more sites','Pulling Search Console clicks (28 days)…','Done. 3,906 pages, 612 issues, 47 fixes ready.'];
lines.forEach((l,i)=>setTimeout(()=>{const s=document.createElement('span');s.textContent=l;log.appendChild(s);if(i===lines.length-1){btn.textContent='Open portfolio';btn.disabled=false;btn.onclick=()=>go(1)}},i*380));
}
function approve(){
const id=sel,it=ISSUES.find(x=>x.id===id);$('#approve').disabled=true;$('#prog').hidden=false;
const st=['#ps1','#ps2','#ps3'];
st.forEach((s,i)=>{setTimeout(()=>{$(s).className='pstep go'},i*900);setTimeout(()=>{$(s).className='pstep ok';if(i===2){fixed.add(id);health=Math.min(100,health+it.gain);toast(`Fix is live · health ${health-it.gain} → ${health}`);const next=ISSUES.find(x=>!fixed.has(x.id));if(next)sel=next.id;setTimeout(render,700)}},i*900+800)});
}
function render(){$('#canvas').innerHTML=R[SCREENS[cur].id]();bind(SCREENS[cur].id)}
function go(i){
if(i<0||i>=SCREENS.length)return;cur=i;
$('#tourStep').textContent=`Step ${i+1} of ${SCREENS.length} · ${SCREENS[i].name}`;
$('#tourText').innerHTML=SCREENS[i].tour;
document.querySelectorAll('.dot').forEach((d,k)=>d.className='dot'+(k===i?' on':k<i?' done':''));
document.querySelectorAll('.nav button').forEach((b,k)=>b.classList.toggle('on',k===i));
$('#prev').disabled=i===0;$('#next').textContent=i===SCREENS.length-1?'End of tour':'Next step';$('#next').disabled=i===SCREENS.length-1;
render();
try{localStorage.setItem('cx-step',i)}catch(e){}
}
let start=0;try{start=+localStorage.getItem('cx-step')||0}catch(e){}
go(Math.min(start,SCREENS.length-1));
</script>
</body>
</html>