Toutmark
Have an account? Log in · Back to home
`; return stepShell(1, "Pick your plan", "All plans include the AI citation audit, schema work, and weekly reporting. Pick the volume that fits.", body); } /* ============================ STEP 3 — Verify email ============================ LIVE: /api/onboarding/verify-email-request emails a real random 6-digit code (10-min TTL) via Mailrelay; /api/onboarding/verify-email validates it. ===================================================================== */ function stepVerify() { const body = `
We just sent a 6-digit verification code to ${state.email || "your email"}. Enter it below to confirm your address. Stuck? Resend the code.
Check your inbox (and spam folder) for the code — it expires in 10 minutes.
`; return stepShell(3, "Verify your email", "We use this email to set up the publisher accounts that earn your AI citation citations — make sure we can reach you.", body); } /* ============================ STEP 7 (NEW) — Target queries + brand voice ============================ The "spine of AI citation" per Operator + Analyst doctrine. Every customer-facing draft anchors back to one of these queries via Editor's anchor-gate. ============================================================================ */ function stepTargetQueries() { const winnabilityChip = (q, w) => { if (w === "checking") return `Analyzing…`; const colors = { green: "#10b981", yellow: "#f59e0b", red: "#ef4444" }; const labels = { green: "Winnable", yellow: "Competitive", red: "Stretch" }; const c = colors[w] || "#f59e0b", l = labels[w] || "Competitive"; return `${l}`; }; const queryRows = state.target_queries.map((q, i) => `
${winnabilityChip(q.query, q.winnability || "yellow")}
`).join(""); const segments = ["B2B SaaS","B2C / consumer","B2B services","E-commerce","Solo / independent professional","Local services","Enterprise (1000+ employees)","SMB (10–250 employees)","Mid-market (250–1000 employees)","Other"]; const segChips = segments.map(s => ` `).join(""); const body = `
Used by the Brand Strategist agent to lock your voice and seed every draft.
${(state._suggestions && state._suggestions.length) ? `
Suggested examples (optional) — click any to add, or type your own below:
${state._suggestions.map((s, i) => { const added = state.target_queries.some(q => (q.query || "").toLowerCase() === (s.query || "").toLowerCase()); return `
${(s.query || "").replace(/${s.rationale ? `
${(s.rationale || "").replace(/` : ""}
${winnabilityChip(s.query, s.winnability || "yellow")}
`; }).join("")}
` : ""}
${queryRows || '

No queries yet — use Suggest above, or add your own below.

'}
Winnability is assessed live for your brand. Green = realistic to win in 90 days · Yellow = competitive but doable · Red = too broad — you'll struggle to get cited no matter how good the AEO.
To continue: at least 2 Green and 4 Green/Yellow total (reds don't count toward the minimum — 4 Greens works too).
Comma-separated.
${segChips}
Creator agent uses these to lock voice on day 1. Without them, drafts will sound generic until you correct them.
`; return stepShell(7, "Your target queries", "These are the questions you want Claude, ChatGPT, Gemini, and Perplexity to cite you on. Editor's anchor-gate will reject any draft that doesn't tie back to one of these.", body); } /* ============================ STEP 2 — Account ============================ */ function step2() { const stateOpts = STATES.map(s => ``).join(""); const body = `
As soon as you continue, we run a background search on your business + niche and pre-populate 5 likely competitors at the brand-intake step. You can edit, remove, or add more.
Use a mix of letters, numbers, and symbols.
We're US-only at the moment. International coming soon.
`; return stepShell(2, "Create your account", "We use this email to set up the publisher accounts that earn your AI citation citations. Your time zone shapes when reports land and when emails get sent on your behalf.", body); } /* ============================ STEP 3 — Order summary (payment happens on Stripe at the final step) ============================ */ function step3() { const p = PLANS[state.plan]; const body = `
${p.name} plan${p.price}${p.monthly}
Founding-25 rate lockApplied automatically if a founding seat remains
One-time onboarding fee$25 · waived for founding members
Due today${p.price} (+$25 if the founding 25 is full)
Powered by Stripe. Card details are entered only in Stripe's secure payment form — we never see your full card number. Cancel from Billing any time; your subscription ends at the close of the current period.
`; return stepShell(4, "Review your order", "Confirm your plan and billing details. Payment itself happens at the final step through Stripe's secure form — your card is charged only after you confirm there.", body, { nextLabel: "Looks right — continue" }); } /* ============================ STEP 5 — Disclosure / consent + FTC AI disclosure ============================ */ function step4() { const body = `
`; return stepShell(5, "Consents & disclosures", "Three quick check-boxes — publisher-account authorization, FTC AI-disclosure acknowledgment, and the refund policy.", body); } /* ============================ STEP 5 — Brand intake ============================ */ function step5() { const tones = ["Professional","Warm","Authoritative","Playful","Data-driven","Direct"]; const toneChips = tones.map(t => ` `).join(""); const indGroups = INDUSTRIES.map(([group, items]) => ` ${items.map(i => ``).join("")} `).join(""); const body = `
Used to seed your industry-education library and shape the voice of every draft.
${toneChips}
Our agents will never put these phrases in your drafts.
Operator + Analyst agents reference this on every audit + competitor scan.
`; return stepShell(6, "Brand intake", "About 60 seconds. The Brand Strategist uses this to lock your voice. The Analyst tracks the competitor list silently — no public name-comparisons. Logo + fonts are pulled from your homepage automatically.", body); } /* ============================ STEP 8 — Connect site + review-platform claim flow ============================ */ function step6() { const ynRow = (key, labelText) => { const v = state[key]; return `
${labelText}
`; }; // G2 + Capterra block: moved out of signup as of 2026-06-01. // Customers now answer platform questions on /app/onboarding/platforms after signup completes — // they get per-platform walkthroughs (setup OR connect) followed by dashboard reminders. // Industry + plan gating now runs on that page via /api/customer/me. const showReviewBlock = false; const listingUrlInput = (yKey, urlKey, label, exampleHost) => state[yKey] === "y" ? `
Paste your listing URL — we'll use it to confirm the right vendor profile and queue the right walkthrough on your dashboard.
` : ""; const reviewBlock = showReviewBlock ? ` ` : ""; // Google Business Profile claim flow — industry-gated (hidden for pure-digital industries). // GBP is on all plan tiers (Starter / Growth / Scale per pricing matrix), so no plan gate. // The Reviewer agent waits for all 3 prereq states (claimed → manager added → Google verified) // before drafting any GBP review responses. Customer is NOT billed for GBP work during the // 5–14 day postcard-verification wait (Spend Manager check). // GBP block: moved out of signup as of 2026-06-01 — handled on /app/onboarding/platforms post-signup. const showGbpBlock = false; const gbpUrlInput = state.has_gbp === "y" ? `
Paste from your Google Maps listing or the Manage page on business.google.com.
` : ""; const gbpBlock = showGbpBlock ? ` ` : ""; // Domain ownership verification — TXT, file, plugin, or OAuth const verifyToken = "tm_verify_" + ((state.email || "preview").replace(/[^a-zA-Z0-9]/g,"").slice(0,12) || "preview"); const domainGuess = state.domain_to_verify || ((state.email || "").split("@")[1] || ""); const verifyBlock = ` `; // Per-platform install path const installBlock = ` `; // Subdomain pick (used for the readonly mirror + GBP embed) + firm-profile path const subdomainHint = state.subdomain_pick || (state.company || "").toLowerCase().replace(/[^a-z0-9]/g,"").slice(0,24); const profileDefault = state.is_regulated ? "/firm-profile" : "/ai-profile"; const subBlock = ` `; const body = `
We need read access to your site so the Webmaster agent can ship paragraph rewrites, schema, and llms.txt as queued drafts. Read access only — nothing publishes without your approval.
${verifyBlock} ${installBlock} ${subBlock} ${reviewBlock} ${gbpBlock} `; return stepShell(8, "Connect your site", "Domain verification first, then pick your install path. The Webmaster will only ever read or queue — never publish without you.", body, { nextLabel: "Continue" }); } /* ============================ STEP 9 — Spokesperson attestation + press approver ============================ */ function step7() { const showApprover = state.plan === "growth" || state.plan === "scale"; const approverBlock = showApprover ? ` ` : ""; // HARO category preferences const haroCats = [ "Business & Finance","Healthcare","Technology","Marketing & Advertising","Real Estate", "Legal & Compliance","Education","Energy & Environment","Retail & E-commerce","Manufacturing", "Hospitality & Travel","Entertainment & Media","Lifestyle & Wellness","Public Affairs" ]; const haroCatChips = haroCats.map(c => ` `).join(""); const haroBlock = showApprover ? ` ` : ""; const body = `
Why we ask. HARO and journalist-pitch features quote a real person at your company. We need that person on file — name, title, email — and a one-time attestation that they're authorized to speak for the brand.
${(state.spokesperson_contact_method === "email" || state.spokesperson_contact_method === "both") ? `
` : ""} ${(state.spokesperson_contact_method === "phone" || state.spokesperson_contact_method === "both") ? `
` : ""}
${state.spokesperson_headshot_url ? `` : "no photo"}

PNG/JPG, max 10 MB. Journalists explicitly ask for one — pickup rate is materially higher when a HARO pitch includes a face. You can add this later from the Asset Library, but adding it now means HARO and EIN pitches start working immediately.

${state.spokesperson_headshot_uploaded ? `

✓ Headshot uploaded

` : ""}
${approverBlock} ${haroBlock} `; return stepShell(9, "Spokesperson & press approver", "Optional, but unlocks HARO replies, journalist pitches, and press releases once it's done.", body); } /* ============================ STEP 10 — Regulated mode + CCO + securities-specific fields ============================ */ function step8() { const isReg = REGULATED_INDUSTRIES.has(state.industry); let banner = ""; if (isReg) { banner = `
${state.industry} is on our regulated-industry list. Toutmark requires the Scale plan for these industries because every draft routes through the Compliance Queue and our spec for SEC, FINRA, HIPAA, and state-bar review applies. ${state.plan !== "scale" ? `We've upgraded your plan selection to Scale.` : ""}
`; if (state.plan !== "scale") state.plan = "scale"; state.is_regulated = true; state.regulated_mode = "Y"; } // Securities-specific industries (more rules apply per worker SECURITIES_REGEX) const SEC_INDUSTRIES = new Set([ "Investment management / asset management","Wealth management firm","Financial planning / RIA", ]); state.is_securities = SEC_INDUSTRIES.has(state.industry); const yn = `
`; const showCCO = state.regulated_mode === "Y" || isReg; const ccoBlock = showCCO ? ` ` : ""; const securitiesBlock = (showCCO && state.is_securities) ? ` ` : ""; const body = ` ${banner}

Are you in financial services, legal, healthcare, insurance, government, energy/utilities, accounting, securities, real estate, or another regulated industry?

${isReg ? `
Locked to Yes based on your industry pick at Step 6.
` : yn} ${ccoBlock} ${securitiesBlock} `; return stepShell(10, "Regulated industry?", "If you say yes, every draft routes through the Compliance Queue.", body); } /* ============================ STEP 11 — Full-auto-approve toggle ============================ */ function step9() { const body = ` `; return stepShell(11, "Approval mode + notifications", "Set the queue/auto rules, where you want pings, and how often we email reports. All of these are editable any time from Settings.", body); } /* ---- Bot-readiness host picker (Owner directive 2026-06-17) ---------------- */ // On a failed reachability check we ask which platform the customer is on, then // load the exact per-host fix. Settings-toggle hosts hide the robots.txt paste; // robots-editable hosts show it. const BOT_FIX_PLATFORMS = [ { group: "Website builders & CMS", items: [["wordpress","WordPress"],["shopify","Shopify"],["wix","Wix"],["squarespace","Squarespace"],["webflow","Webflow"],["ghost","Ghost"],["framer","Framer"],["weebly","Weebly"],["square","Square Online"],["duda","Duda"],["carrd","Carrd"],["bigcartel","Big Cartel"],["notion","Notion"],["google_sites","Google Sites"]] }, { group: "Hosting & platforms", items: [["cloudflare","Cloudflare"],["vercel","Vercel"],["netlify","Netlify"],["godaddy","GoDaddy"],["siteground","SiteGround"],["wpengine","WP Engine"],["kinsta","Kinsta"],["bluehost","Bluehost"],["hostinger","Hostinger"],["cloudways","Cloudways"]] }, { group: "Behind a CDN / firewall, or self-hosted", items: [["cloudfront","AWS CloudFront"],["fastly","Fastly"],["akamai","Akamai"],["sucuri","Sucuri"],["imperva","Imperva"],["datadome","DataDome"],["human","HUMAN / PerimeterX"],["kasada","Kasada"],["apache","Apache (.htaccess)"],["nginx","nginx"],["modsecurity","ModSecurity"]] }, { group: "", items: [["generic","Other / I'm not sure"]] }, ]; const BOT_FIX_TOGGLE_ONLY = ["squarespace","cloudflare","kinsta","cloudways","siteground","wpengine","sucuri","imperva","akamai","fastly","cloudfront","aws","awswaf","datadome","human","perimeterx","kasada","modsecurity"]; function botFixPlatformLabel(key) { for (const g of BOT_FIX_PLATFORMS) for (const it of g.items) if (it[0] === key) return it[1]; return key || "your platform"; } function renderBotFixPicker() { return BOT_FIX_PLATFORMS.map(g => (g.group ? '

' + g.group + '

' : '') + '
' + g.items.map(it => '').join('') + '
' ).join(''); } /* ============================ STEP 12 — AI citation BOT-READINESS CHECK (task #458) ============================ */ function stepBotReadiness() { const r = state.bot_readiness_result; const loading = state.bot_readiness_loading; let body; if (loading) { body = `
🛰️

Checking that AI crawlers can reach your site…

Connecting to your site…

This takes about 15 seconds.

`; } else if (!r) { body = `

We're about to probe your site as if we were each of the major AI crawlers. If any are blocked, every paragraph rewrite, schema deploy, and llms.txt update we ship will go to waste.

`; } else if (r.severity === "ok") { body = `

All 5 AI bots can read your site.

Onboarding complete. Heading to your dashboard…

`; } else { // FAIL (warn or critical): ask which host, then show the exact per-host fix. const crit = r.severity === "critical"; const blocked = Object.values(r.bots || {}).filter(b => b.classification !== "open").map(b => b.label).join(", "); const reachable = Object.values(r.bots || {}).filter(b => b.classification === "open").length; const banner = crit ? `
🔴 Only ${reachable} AI crawler${reachable === 1 ? "" : "s"} can reach your site.

You need at least 3 of 5. Blocked: ${blocked}. Let's get it fixed.

` : `
⚠️ Only ${reachable} AI crawlers can reach your site.

You need at least 3 of 5. Blocked: ${blocked}. One quick fix and you're set.

`; if (!state.bot_fix_platform) { body = banner + `

Which platform is your website on?

Pick it and we'll give you the exact steps for your host.

${renderBotFixPicker()}
`; } else { const picked = state.bot_fix_platform; const showRobots = BOT_FIX_TOGGLE_ONLY.indexOf(picked) === -1; body = banner + `

Your platform: ${botFixPlatformLabel(picked)} · change

Loading the exact steps for ${botFixPlatformLabel(picked)}…
${showRobots ? '
' : ''}

Made the change? If your platform needs it, publish first. Updates can take a minute or two to go live — and if you use a CDN, purge its cache — then click re-check.

`; } } return stepShell(12, "Verify AI bots can reach your site", "Toutmark is wasted effort if AI crawlers can't fetch your pages. We probe 5 of them now.", body, { hideActions: r && r.severity !== "ok" }); } /* ============================ STEP 12 — Welcome + payment (embedded Stripe) ============================ */ function step10() { const payUrl = state.checkout_url || ""; const canEmbed = !!(state.checkout_client_secret && state.stripe_publishable_key); const p = PLANS[state.plan] || { name: state.plan, price: "", monthly: "/mo" }; let body; if (canEmbed) { // Branded on-site checkout: our order summary + Stripe's secure embedded // payment form, mounted below by mountEmbeddedCheckout(). No redirect. body = `

Last step, ${state.company || "you"} — activate your subscription.

${p.name} plan${p.price}${p.monthly}
Founding-25 rate lockApplied if a founding seat remains
Due todayshown in the secure form below
🔒

Loading the secure payment form…

Payment form by Stripe — we never see your card number. You'll land on your dashboard right after.
`; } else if (payUrl) { body = `
🎉

Last step, ${state.company || "you"} — activate your subscription.

Your intake is saved. Enter payment securely through Stripe to switch your agents on — you'll land on your dashboard right after.

Enter payment & activate →
Secure checkout by Stripe. Your card is charged only after you confirm.
`; } else { body = `

Almost there, ${state.company || "you"}.

Your intake is saved, but we couldn't start secure checkout automatically. Email hello@toutmark.com and we'll activate your subscription right away.

`; } return `
Step 13 of 13 All done
${body}
`; } /* Mount Stripe Embedded Checkout into the welcome step. Falls back to the hosted-redirect button on ANY failure (script blocked, bad key, etc.). */ let _tmEmbeddedMounted = false; /* ---- Full-screen "working…" overlay so async waits never look blank --------- */ let _wizBusyTimer = null; function showWizardBusy(msg, subs) { let o = document.getElementById("wiz-busy"); if (!o) { o = document.createElement("div"); o.id = "wiz-busy"; o.style.cssText = "position:fixed;inset:0;background:rgba(255,255,255,0.94);display:flex;align-items:center;justify-content:center;z-index:9999;"; document.body.appendChild(o); } o.innerHTML = '
' + '
' + '

' + (msg || "Working…") + '

' + '

'; o.style.display = "flex"; if (!document.getElementById("wiz-busy-style")) { const st = document.createElement("style"); st.id = "wiz-busy-style"; st.textContent = "@keyframes wizspin{to{transform:rotate(360deg)}}"; document.head.appendChild(st); } if (_wizBusyTimer) { clearInterval(_wizBusyTimer); _wizBusyTimer = null; } const list = Array.isArray(subs) ? subs : (subs ? [subs] : []); const sub = document.getElementById("wiz-busy-sub"); if (sub && list.length) { let i = 0; sub.textContent = list[0]; if (list.length > 1) { _wizBusyTimer = setInterval(() => { i = (i + 1) % list.length; const e = document.getElementById("wiz-busy-sub"); if (e) e.textContent = list[i]; }, 1500); } } } function hideWizardBusy() { if (_wizBusyTimer) { clearInterval(_wizBusyTimer); _wizBusyTimer = null; } const o = document.getElementById("wiz-busy"); if (o) o.style.display = "none"; } function mountEmbeddedCheckout() { if (_tmEmbeddedMounted) return; const target = document.getElementById("tm-embedded-checkout"); if (!target || !state.checkout_client_secret || !state.stripe_publishable_key) return; _tmEmbeddedMounted = true; const fail = () => { try { const fb = document.getElementById("tm-embed-fallback"); if (fb) fb.style.display = ""; target.style.display = "none"; } catch (e) {} }; const init = async () => { try { const stripe = Stripe(state.stripe_publishable_key); const checkout = await stripe.initEmbeddedCheckout({ clientSecret: state.checkout_client_secret }); checkout.mount("#tm-embedded-checkout"); } catch (e) { fail(); } }; if (typeof Stripe !== "undefined") { init(); return; } const s = document.createElement("script"); s.src = "https://js.stripe.com/v3/"; s.onload = init; s.onerror = fail; document.head.appendChild(s); } /* ============================ Step bind / validate / advance ============================ */ /* Step map (12-step): 1 Pick plan 2 Account 3 Verify email (NEW — code = "000000" in Live Phase) 4 Stripe checkout 5 Disclosure + AI-disclosure consent 6 Brand intake 7 Target queries (NEW) 8 Connect site + G2/Capterra toggles 9 Spokesperson + press approver 10 Regulated mode + CCO + securities fields 11 Full auto-approve 12 Welcome */ function bind() { const back = $("#btn-back"); if (back) back.addEventListener("click", () => { state.step--; render(); }); const next = $("#btn-next"); if (next) next.addEventListener("click", advance); if (state.step === 1) { document.querySelectorAll('input[name="plan"]').forEach(r => r.addEventListener("change", e => state.plan = e.target.value)); } if (state.step === 2) { $("#f-email").addEventListener("input", e => state.email = e.target.value.trim()); $("#f-password").addEventListener("input", e => { state.password = e.target.value; const sBox = $("#pw-strength"); const len = e.target.value.length; if (len === 0) sBox.textContent = "Use a mix of letters, numbers, and symbols."; else if (len < 12) { sBox.textContent = `Need ${12 - len} more characters.`; sBox.style.color = "var(--c-warn)"; } else { sBox.textContent = "Strong enough. ✓"; sBox.style.color = "var(--c-ok)"; } }); $("#f-company").addEventListener("input", e => state.company = e.target.value.trim()); $("#f-state").addEventListener("change", e => state.state_us = e.target.value); const tz = $("#f-tz"); if (tz) tz.addEventListener("change", e => state.timezone = e.target.value); const ws = $("#f-website"); if (ws) ws.addEventListener("input", e => state.website = e.target.value.trim()); } if (state.step === 3) { const v = $("#f-verify"); if (v) v.addEventListener("input", e => state.email_verify_code = e.target.value.replace(/\D/g,"").slice(0,6)); // Auto-send code the first time the user lands on step 3. if (!state.verify_code_sent && state.email) { state.verify_code_sent = true; fetch("/api/onboarding/verify-email-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: state.email }) }) .then(r => r.json().catch(() => ({}))) .then(j => { const h = $("#verify-help"); if (h && j && j.ok) h.innerHTML = "Check your inbox \u2014 the 6-digit code should arrive within a minute. Stuck? Resend."; }) .catch(() => {}); } const r = $("#resend-code"); if (r) r.addEventListener("click", async (e) => { e.preventDefault(); const h = $("#verify-help"); if (h) h.innerHTML = "Sending a fresh code\u2026"; try { const rr = await fetch("/api/onboarding/verify-email-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: state.email }) }); const rj = await rr.json().catch(() => ({})); if (rr.ok && rj.ok) { if (h) h.innerHTML = "Code resent. Check your inbox \u2014 it should arrive within a minute."; } else if (rj.error === "rate_limited") { if (h) h.innerHTML = "Please wait. One resend per minute."; } else { if (h) h.innerHTML = "Resend failed. Refresh and try again."; } } catch (err) { if (h) h.innerHTML = "Network error. Try again in a moment."; } }); } if (state.step === 4) { // No card inputs on this step — card entry happens only inside Stripe's // secure embedded form at the final step (PCI stays with Stripe). const ba = $("#f-bill-addr"); if (ba) ba.addEventListener("input", e => state.billing_address = e.target.value); const bc = $("#f-bill-city"); if (bc) bc.addEventListener("input", e => state.billing_city = e.target.value); const bp = $("#f-bill-postal"); if (bp) bp.addEventListener("input", e => state.billing_postal = e.target.value); document.querySelectorAll('input[name="pm"]').forEach(r => r.addEventListener("change", e => { state.payment_method = e.target.value; render(); })); const ppe = $("#f-pp-email"); if (ppe) ppe.addEventListener("input", e => state.paypal_email = e.target.value.trim()); } if (state.step === 5) { const c = $("#f-consent"); if (c) c.addEventListener("change", e => state.consent_disclosure = e.target.checked); const ai = $("#f-ai-disclosure"); if (ai) ai.addEventListener("change", e => state.consent_ai_disclosure = e.target.checked); const rf = $("#f-refund-ack"); if (rf) rf.addEventListener("change", e => state.ack_refund_policy = e.target.checked); } if (state.step === 6) { $("#f-industry").addEventListener("change", e => { state.industry = e.target.value; const wrap = $("#industry-other-wrap"); if (state.industry === "Other (describe)") wrap.style.display = ""; else wrap.style.display = "none"; }); const other = $("#f-industry-other"); if (other) other.addEventListener("input", e => state.industry_other = e.target.value); $("#f-tagline").addEventListener("input", e => state.brand.tagline = e.target.value); $("#f-services").addEventListener("input", e => state.brand.services = e.target.value); $("#f-diff").addEventListener("input", e => state.brand.differentiators = e.target.value); document.querySelectorAll('.chip input[type="checkbox"]').forEach(cb => cb.addEventListener("change", e => { const v = e.target.value; if (e.target.checked) state.brand.tone.push(v); else state.brand.tone = state.brand.tone.filter(t => t !== v); })); $("#f-banned").addEventListener("input", e => state.brand.banned = e.target.value); $("#f-press").addEventListener("input", e => state.brand.press = e.target.value); const uc = $("#f-usecase"); if (uc) uc.addEventListener("input", e => state.use_case_summary = e.target.value); const cp = $("#f-color-pri"); if (cp) cp.addEventListener("input", e => state.brand_color_primary = e.target.value); const ca = $("#f-color-acc"); if (ca) ca.addEventListener("input", e => state.brand_color_accent = e.target.value); const cbg = $("#f-color-bg"); if (cbg) cbg.addEventListener("input", e => state.brand_color_bg = e.target.value); const ct = $("#f-color-txt"); if (ct) ct.addEventListener("input", e => state.brand_color_text = e.target.value); // Per-row competitor editing document.querySelectorAll(".comp-name").forEach(inp => inp.addEventListener("input", e => { const i = parseInt(e.target.dataset.i, 10); if (state.competitors[i]) state.competitors[i].name = e.target.value; })); document.querySelectorAll(".comp-url").forEach(inp => inp.addEventListener("input", e => { const i = parseInt(e.target.dataset.i, 10); if (state.competitors[i]) state.competitors[i].url = e.target.value; })); document.querySelectorAll('[data-act="del-comp"]').forEach(b => b.addEventListener("click", e => { const i = parseInt(e.target.dataset.i, 10); state.competitors.splice(i, 1); render(); })); const ca2 = $("#comp-add"); if (ca2) ca2.addEventListener("click", () => { state.competitors.push({ name: "", url: "" }); render(); }); } if (state.step === 9) { if (!state.spokesperson_attested) { // Soft skip — handled by skip button; no hard block } if (state.plan === "growth" || state.plan === "scale") { if (!state.press_approver_name || !state.press_approver_title) return alert("Add the press release approver's name and title."); const pm = state.press_approver_contact_method || "email"; if ((pm === "email" || pm === "both") && !state.press_approver_email) return alert("Add the approver's email."); if ((pm === "phone" || pm === "both") && !state.press_approver_phone) return alert("Add the approver's phone number."); } } if (state.step === 7) { const pos = $("#f-positioning"); if (pos) pos.addEventListener("input", e => state.positioning_statement = e.target.value); const geo = $("#f-geo"); if (geo) geo.addEventListener("change", e => state.geographic_scope = e.target.value); const cats = $("#f-categories"); if (cats) cats.addEventListener("input", e => state.target_categories = e.target.value.split(",").map(s => s.trim()).filter(Boolean)); const voice = $("#f-voice"); if (voice) voice.addEventListener("input", e => state.brand.voice_samples = e.target.value); document.querySelectorAll('.chip-group input[type="checkbox"]').forEach(cb => cb.addEventListener("change", e => { const v = e.target.value; if (e.target.checked && !state.customer_segments.includes(v)) state.customer_segments.push(v); else state.customer_segments = state.customer_segments.filter(s => s !== v); })); document.querySelectorAll('.tq-input').forEach(inp => inp.addEventListener("input", e => { const i = parseInt(e.target.dataset.i, 10); if (state.target_queries[i]) state.target_queries[i].query = e.target.value; })); document.querySelectorAll('[data-act="del-q"]').forEach(b => b.addEventListener("click", e => { const i = parseInt(e.target.dataset.i, 10); state.target_queries.splice(i, 1); render(); })); const _qctx = () => ({ brand_name: state.company, company: state.company, industry: state.industry, url: state.website, website: state.website, positioning_statement: state.positioning_statement, geographic_scope: state.geographic_scope, customer_segments: state.customer_segments, target_categories: state.target_categories }); const _tqMax = () => ({ starter: 6, growth: 8, scale: 12 })[state.plan] || 6; const addBtn = $("#tq-add"); if (addBtn) addBtn.addEventListener("click", async () => { const inp = $("#tq-new"); const q = (inp.value || "").trim(); if (!q) return; if (state.target_queries.length >= _tqMax()) { alert(`You've reached your plan's limit of ${_tqMax()} target queries. Remove one to add another, or upgrade your plan for more.`); return; } state.target_queries.push({ query: q, winnability: "checking" }); inp.value = ""; render(); // After 5s, show a provisional "Competitive (yellow)" so they're never stuck waiting — // but keep the request running and overwrite with the real color the moment it lands. const _prov = setTimeout(() => { const t = state.target_queries.find(x => x.query === q); if (t && t.winnability === "checking") { t.winnability = "yellow"; render(); } }, 5000); try { const r = await fetch("/api/onboarding/assess-query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ..._qctx(), query: q }) }); const j = await r.json().catch(() => ({})); const tq = state.target_queries.find(x => x.query === q); if (tq) tq.winnability = (j && j.winnability) || "yellow"; } catch { const tq = state.target_queries.find(x => x.query === q); if (tq) tq.winnability = "yellow"; } clearTimeout(_prov); render(); }); const sgBtn = $("#tq-suggest"); if (sgBtn) sgBtn.addEventListener("click", async () => { const statusEl = $("#tq-suggest-status"); if (!state.website) { if (statusEl) statusEl.textContent = "Add your website on the account step first."; return; } sgBtn.disabled = true; if (statusEl) statusEl.textContent = "Reading your site + finding winnable queries… (~15s)"; try { const r = await fetch("/api/onboarding/suggest-queries", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(_qctx()) }); const j = await r.json().catch(() => ({})); state._suggestions = (j && Array.isArray(j.queries)) ? j.queries : []; render(); if (!state._suggestions.length) { const s2 = $("#tq-suggest-status"); if (s2) s2.textContent = "Couldn't generate suggestions — add your own below."; } } catch { sgBtn.disabled = false; if (statusEl) statusEl.textContent = "Couldn't reach the server — add your own below."; } }); document.querySelectorAll('[data-act="add-sugg"]').forEach(b => b.addEventListener("click", () => { const s = (state._suggestions || [])[parseInt(b.dataset.i, 10)]; if (s && !state.target_queries.some(q => (q.query || "").toLowerCase() === (s.query || "").toLowerCase())) { if (state.target_queries.length >= _tqMax()) { alert(`You've reached your plan's limit of ${_tqMax()} target queries. Remove one to add another, or upgrade your plan for more.`); return; } state.target_queries.push({ query: s.query, winnability: s.winnability || "yellow" }); render(); } }); } if (state.step === 8) { // Domain ownership verification const dv = $("#f-vdomain"); if (dv) dv.addEventListener("input", e => state.domain_to_verify = e.target.value.trim().toLowerCase()); document.querySelectorAll('input[name="vmethod"]').forEach(r => r.addEventListener("change", e => state.domain_verification_method = e.target.value)); // Install path picker document.querySelectorAll('input[name="ipath"]').forEach(r => r.addEventListener("change", e => { state.install_path = e.target.value; state.site_connected = (e.target.value !== "skip"); render(); })); const wfs = $("#f-wf-site"); if (wfs) wfs.addEventListener("input", e => state.webflow_site_id = e.target.value.trim()); // Subdomain + firm-profile path const sd = $("#f-subdom"); if (sd) sd.addEventListener("input", e => state.subdomain_pick = e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g,"").slice(0,32)); const pp = $("#f-profilepath"); if (pp) pp.addEventListener("input", e => state.firm_profile_path = e.target.value.trim()); // Review-platform listings (G2, Capterra) + GBP ["has_g2","has_capterra","has_gbp"].forEach(key => { document.querySelectorAll(`input[name="${key}"]`).forEach(r => r.addEventListener("change", e => { state[key] = e.target.value; render(); })); }); const g2u = $("#f-g2_listing_url"); if (g2u) g2u.addEventListener("input", e => state.g2_listing_url = e.target.value.trim()); const capu = $("#f-capterra_listing_url"); if (capu) capu.addEventListener("input", e => state.capterra_listing_url = e.target.value.trim()); const gbpu = $("#f-gbp_listing_url"); if (gbpu) gbpu.addEventListener("input", e => state.gbp_listing_url = e.target.value.trim()); } if (state.step === 9) { const att = $("#f-sp-attest"); if (att) att.addEventListener("change", e => state.spokesperson_attested = e.target.checked ? true : null); const skip = $("#skip-sp"); if (skip) skip.addEventListener("click", () => { state.spokesperson_attested = false; advance(); }); const sph = $("#f-sp-phone"); if (sph) sph.addEventListener("input", e => state.spokesperson_phone = e.target.value); document.querySelectorAll('input[name="sp_cm"]').forEach(r => r.addEventListener("change", e => { state.spokesperson_contact_method = e.target.value; render(); })); // Spokesperson headshot — preview locally, defer upload until step submit (customer doesn't have an ID yet) const sphHead = $("#f-sp-headshot"); if (sphHead) sphHead.addEventListener("change", e => { const file = e.target.files && e.target.files[0]; if (!file) return; if (file.size > 10 * 1024 * 1024) { alert("File too large (max 10 MB)"); e.target.value = ""; return; } // Stash file on state for upload at step submit; render preview now state._spokesperson_headshot_file = file; state.spokesperson_headshot_uploaded = true; const reader = new FileReader(); reader.onload = (ev) => { state.spokesperson_headshot_url = ev.target.result; render(); }; reader.readAsDataURL(file); }); document.querySelectorAll('input[name="pa_cm"]').forEach(r => r.addEventListener("change", e => { state.press_approver_contact_method = e.target.value; render(); })); const pn = $("#f-pa-name"); if (pn) pn.addEventListener("input", e => state.press_approver_name = e.target.value); const pt = $("#f-pa-title"); if (pt) pt.addEventListener("input", e => state.press_approver_title = e.target.value); const pe = $("#f-pa-email"); if (pe) pe.addEventListener("input", e => state.press_approver_email = e.target.value.trim()); const pph = $("#f-pa-phone"); if (pph) pph.addEventListener("input", e => state.press_approver_phone = e.target.value); document.querySelectorAll('input[name="pa_scope"]').forEach(r => r.addEventListener("change", e => state.press_approver_scope = e.target.value)); // HARO categories (chip-group multi-select) document.querySelectorAll('input[id^="haro-"]').forEach(cb => cb.addEventListener("change", e => { const v = e.target.value; if (e.target.checked && !state.haro_categories.includes(v)) state.haro_categories.push(v); else state.haro_categories = state.haro_categories.filter(c => c !== v); })); } if (state.step === 10) { document.querySelectorAll('input[name="regmode"]').forEach(r => r.addEventListener("change", e => { state.regulated_mode = e.target.value; render(); })); const cn = $("#f-cco-name"); if (cn) cn.addEventListener("input", e => state.cco_name = e.target.value); const ct = $("#f-cco-title"); if (ct) ct.addEventListener("input", e => state.cco_title = e.target.value); const ce = $("#f-cco-email"); if (ce) ce.addEventListener("input", e => state.cco_email = e.target.value.trim()); const cph = $("#f-cco-phone"); if (cph) cph.addEventListener("input", e => state.cco_phone = e.target.value); document.querySelectorAll('input[name="cco_cm"]').forEach(r => r.addEventListener("change", e => { state.cco_contact_method = e.target.value; render(); })); const rc = $("#f-reg-cat"); if (rc) rc.addEventListener("change", e => { state.regulator_category = e.target.value; render(); }); const lt = $("#f-lic-type"); if (lt) lt.addEventListener("change", e => state.license_number_type = e.target.value); const ln = $("#f-lic-num"); if (ln) ln.addEventListener("input", e => state.license_number = e.target.value); const ju = $("#f-jurisdictions"); if (ju) ju.addEventListener("input", e => state.licensed_jurisdictions = e.target.value.split(",").map(s => s.trim().toUpperCase()).filter(Boolean)); const baa = $("#f-baa-ack"); if (baa) baa.addEventListener("change", e => state.baa_acknowledged = e.target.checked); const pri = $("#f-priv-ack"); if (pri) pri.addEventListener("change", e => state.privileged_info_ack = e.target.checked); const dok = $("#f-disc-ok"); if (dok) dok.addEventListener("change", e => state.disclaimer_default_accepted = e.target.checked); const dcus = $("#f-disc-custom"); if (dcus) dcus.addEventListener("input", e => state.disclaimer_custom = e.target.value); const ap = $("#f-adpolicy"); if (ap) ap.addEventListener("input", e => state.advertising_policy_url = e.target.value.trim()); const sr = $("#f-sec-reg"); if (sr) sr.addEventListener("change", e => state.securities_primary_regulator = e.target.value); const sa = $("#f-sec-adv"); if (sa) sa.addEventListener("input", e => state.securities_form_adv_url = e.target.value.trim()); const rd = $("#f-sec-regd"); if (rd) rd.addEventListener("change", e => { state.securities_reg_d = e.target.checked; render(); }); const rdt = $("#f-sec-regd-type"); if (rdt) rdt.addEventListener("change", e => state.securities_reg_d_type = e.target.value); const ex = $("#f-sec-excl"); if (ex) ex.addEventListener("input", e => state.securities_funds_excluded = e.target.value); const sg = $("#f-sec-gate"); if (sg) sg.addEventListener("change", e => state.securities_preview_gate_ack = e.target.checked); } if (state.step === 11) { const f = $("#f-full-auto"); if (f) f.addEventListener("change", e => state.full_auto_approve = e.target.checked); // Per-page consent toggles (consent + mode for each page we'll build) ["brand_profile","at_a_glance","qa_page","industry_education","comparison_page"].forEach(pageId => { const consentInput = $("#pgc-" + pageId); if (consentInput) consentInput.addEventListener("change", e => { if (!state.per_page_consent) state.per_page_consent = {}; if (!state.per_page_consent[pageId]) state.per_page_consent[pageId] = { consent: true, mode: "queue" }; state.per_page_consent[pageId].consent = e.target.checked; }); document.querySelectorAll(`input[name="pgm_${pageId}"]`).forEach(r => r.addEventListener("change", e => { if (!state.per_page_consent) state.per_page_consent = {}; if (!state.per_page_consent[pageId]) state.per_page_consent[pageId] = { consent: true, mode: "queue" }; state.per_page_consent[pageId].mode = e.target.value; })); }); // Per-feature toggles // Wire ALL feature keys — must match the FEATURES list in step9 above. ["paragraph_rewrites","blog_posts","schema","llms_txt","faq","wikidata","brand_profile","firm_profile","at_a_glance","industry_education","rankings","g2_reviews","capterra_reviews","gbp_reviews","gpt_shopping","haro_pitches","press_releases","reddit_posts","quora_posts","qa_candidates","target_queries"].forEach(feat => { document.querySelectorAll(`input[name="pfa_${feat}"]`).forEach(r => r.addEventListener("change", e => { if (!state.per_feature_approval) state.per_feature_approval = {}; state.per_feature_approval[feat] = e.target.value; })); }); // Notification preferences const nfe = $("#nf-email"); if (nfe) nfe.addEventListener("change", e => state.notif_email = e.target.checked); const nfs = $("#nf-slack"); if (nfs) nfs.addEventListener("change", e => state.notif_slack = e.target.checked); const nfd = $("#nf-discord"); if (nfd) nfd.addEventListener("change", e => state.notif_discord = e.target.checked); // Reporting cadence document.querySelectorAll('input[name="rc"]').forEach(r => r.addEventListener("change", e => state.reporting_cadence = e.target.value)); } if (state.step === 12) { // AI citation bot-readiness step (task #458). Auto-kick a check on first entry. const start = $("#bot-rd-start"); if (start) start.addEventListener("click", () => runBotReadinessFromWizard()); if (!state.bot_readiness_result && !state.bot_readiness_loading) { // Auto-run the check on entry. setTimeout(() => runBotReadinessFromWizard(), 250); } // "Re-run the check" / "All done — re-check and continue" both re-probe. const recheck = $("#bot-rd-recheck"); if (recheck) recheck.addEventListener("click", () => runBotReadinessFromWizard()); // Mandatory gate: the only non-fix path is "Get setup help" (captures the lead + // alerts the team, does NOT advance). const botHelp = $("#bot-rd-help"); if (botHelp) botHelp.addEventListener("click", () => requestBotSetupHelp()); // Host picker (shown on failure before a platform is chosen). document.querySelectorAll(".bot-rd-plat").forEach(b => b.addEventListener("click", () => { state.bot_fix_platform = b.getAttribute("data-plat"); render(); })); const change = $("#bot-rd-change"); if (change) change.addEventListener("click", (e) => { e.preventDefault(); state.bot_fix_platform = null; render(); }); const robotsBtn = $("#bot-rd-robots-btn"); if (robotsBtn) robotsBtn.addEventListener("click", () => previewRobotsFromWizard()); // Auto-advance when the site passes (>= 3 of 5 crawlers reachable). if (state.bot_readiness_result && state.bot_readiness_result.severity === "ok") { state.onboarding_bot_check_choice = "auto_advance"; setTimeout(() => { if (state.step === 12) advance(); }, 2000); } // Load the exact fix steps for the chosen platform. if (state.bot_readiness_result && state.bot_readiness_result.severity !== "ok" && state.bot_fix_platform) { const platform = String(state.bot_fix_platform || "generic").toLowerCase().trim(); fetch("/api/customer/bot-readiness/instructions?platform=" + encodeURIComponent(platform)) .then(r => r.json()).then(j => { const $f = $("#bot-rd-fixes"); if ($f && j && j.markdown) { $f.innerHTML = '
' + String(j.markdown).replace(/';
     }
    }).catch(() => {});
  }
 }
}

async function runBotReadinessFromWizard() {
 state.bot_readiness_loading = true;
 render();
 // Rotate plain-language status lines so the ~15s probe never looks like a frozen
 // or blank loading screen. Cosmetic only — the real result comes from the fetch.
 const _statusMsgs = [
  "Connecting to your site…",
  "Visiting as GPTBot (ChatGPT's crawler)…",
  "Visiting as ClaudeBot (Claude's crawler)…",
  "Visiting as PerplexityBot…",
  "Visiting as ChatGPT-User…",
  "Visiting as Googlebot…",
  "Reading your robots.txt…",
  "Checking for firewall or bot challenges…",
  "Almost done…",
 ];
 let _si = 1;
 const _statusTimer = setInterval(() => {
  const el = $("#bot-rd-status");
  if (el) { el.textContent = _statusMsgs[_si % _statusMsgs.length]; _si++; }
 }, 1600);
 try {
  const r = await fetch("/api/customer/bot-readiness-check", {
   method: "POST",
   headers: { "Content-Type": "application/json" },
   body: JSON.stringify({ customer_id: state.customer_id || state.email || "signup", site_url: state.website || state.domain_to_verify || "" }),
  });
  const j = await r.json();
  state.bot_readiness_result = (j && j.result) || null;
  state._checked_website = (state.website || state.domain_to_verify || "");
 } catch { state.bot_readiness_result = null; }
 clearInterval(_statusTimer);
 state.bot_readiness_loading = false;
 render();
}

// Mandatory bot-gate escape: capture the prospect as a lead + alert the team so a
// customer who genuinely can't unblock the bots (no infra access / locked
// platform) is never silently lost. Does NOT advance the wizard.
async function requestBotSetupHelp() {
 const btn = $("#bot-rd-help");
 if (btn) { btn.disabled = true; btn.textContent = "Sending…"; }
 const r = state.bot_readiness_result;
 const blocked = r ? Object.values(r.bots || {}).filter(b => b.classification !== "open").map(b => b.label).join(", ") : "";
 try {
  await fetch("/api/onboarding/bot-help", {
   method: "POST", headers: { "Content-Type": "application/json" },
   body: JSON.stringify({ email: state.email || "", company: state.company || "", website: state.website || state.domain_to_verify || "", platform: state.cms_platform || "", blocked_bots: blocked, severity: r ? r.severity : "unknown" }),
  });
 } catch {}
 alert("Got it — we've saved your spot and our team will reach out to help you (or your web admin) get the crawlers unblocked. Forward the fix steps above to whoever manages your site, then come back and re-run the check to finish.");
 if (btn) { btn.disabled = false; btn.textContent = "Can't fix it? Get setup help"; }
}

// Mandatory-gate robots.txt helper: generate the corrected robots.txt from the
// customer's site URL (no login needed — it just reads + rewrites a public file)
// and show it with a copy button so they can paste it into their platform's editor.
async function previewRobotsFromWizard() {
 const out = $("#bot-rd-robots");
 if (out) out.innerHTML = '

Reading your live robots.txt and preparing the corrected version…

'; try { const resp = await fetch("/api/customer/bot-fix/robots-preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ site_url: state.website || state.domain_to_verify || "" }), }); const j = await resp.json(); if (!out) return; if (!resp.ok || !j.ok) { out.innerHTML = '

Couldn\'t generate it: ' + (j.error || "unknown") + '

'; return; } const esc = s => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); const note = j.changed ? 'Paste this into your robots.txt — it keeps all your other rules and just frees the AI bots:' : 'Your robots.txt already allows the AI bots — no change needed there.'; out.innerHTML = '

' + note + '

' + '
' + esc(j.merged) + '
' + (j.changed ? '' : ''); const copyBtn = $("#bot-rd-robots-copy"); if (copyBtn) copyBtn.addEventListener("click", () => { try { navigator.clipboard.writeText(j.merged); copyBtn.textContent = "Copied!"; } catch (_) {} }); } catch (e) { if (out) out.innerHTML = '

Couldn\'t generate it: ' + (e.message || e) + '

'; } } function showErr(id, msg) { const e = $(id); if (e) e.textContent = msg; } /* ============================ Competitor auto-search ============================ LIVE PHASE STUB: returns 5 placeholder competitors so the wizard demonstrates the pre-population UX. The Live-Phase 8-hour sprint MUST replace this with a real call to `/api/onboarding/competitor-search` which fans out to Brave + Perplexity using state.company + state.industry + state.website, scrapes the top 5 SERP results, and returns {name, url} pairs. The Analyst soul references the same endpoint for re-scans — keep the contract stable. ================================================================================ */ async function runCompetitorAutoSearch() { state.competitor_search_done = true; state._searched_sig = [state.website, state.company, state.industry].join("|"); // LIVE: real competitor search via Brave + Perplexity (Analyst soul uses the same endpoint). try { const r = await fetch("/api/onboarding/competitor-search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_name: state.company, company: state.company, industry: state.industry, url: state.website, website: state.website, state: state.state_us }), }); if (r.ok) { const j = await r.json(); if (Array.isArray(j.competitors) && j.competitors.length && (!state.competitors || state.competitors.length === 0)) { state.competitors = j.competitors.slice(0, 5); } } } catch (e) { /* on failure leave competitors empty — the user adds them manually on this step */ } } async function submitSignup() { // Create the customer + a real Stripe Checkout session from the COMPLETE intake. // Returns { customer_id, checkout_url } and sets the customer session cookie. // The Welcome step redirects to checkout_url so the customer actually pays. state.signup_error = ""; try { const r = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state), }); const j = await r.json().catch(() => ({})); if (r.ok && j.customer_id) { state.customer_id = j.customer_id; state.checkout_url = j.checkout_url || ""; state.checkout_client_secret = j.checkout_client_secret || ""; state.stripe_publishable_key = j.stripe_publishable_key || ""; } else { state.signup_error = j.message || j.error || "We couldn't complete your signup. Please try again."; } } catch (e) { state.signup_error = "Couldn't reach the server. Please try again in a moment."; } } async function advance() { if (state.step === 2) { if (!state.email || !/.+@.+\..+/.test(state.email)) return showErr("#step2-err", "Enter a valid email."); if (state.password.length < 12) return showErr("#step2-err", "Password needs at least 12 characters."); if (!state.company) return showErr("#step2-err", "Add your company name."); if (!state.state_us) return showErr("#step2-err", "Pick your US state. (We're US-only for now.)"); if (!state.website) return showErr("#step2-err", "Add your company website. We use it to find your competitors automatically."); // Kick off competitor auto-search — Live Phase stub, Live Phase wires real search. if (!state.competitor_search_done) runCompetitorAutoSearch(); } if (state.step === 3) { // Real verification — POSTs to /api/onboarding/verify-email with the entered code. if (!/^\d{6}$/.test(state.email_verify_code || "")) { return showErr("#step3-err", `Enter the 6-digit code we sent to ${state.email || "your email"}.`); } showWizardBusy("Verifying your email…", "Checking the code you entered…"); try { const vr = await fetch("/api/onboarding/verify-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: state.email, code: state.email_verify_code }) }); const vj = await vr.json().catch(() => ({})); hideWizardBusy(); if (!vr.ok || !vj.ok) { return showErr("#step3-err", vj.error === "code_mismatch" ? "That code didn't match. Try again." : (vj.error === "code_expired_or_not_found" ? "Code expired. Click Resend to get a new one." : "Verification failed. Please try again.")); } state.email_verified = true; state._verified_email = state.email; } catch (e) { hideWizardBusy(); return showErr("#step3-err", "Could not reach the verifier. Try again in a moment."); } } if (state.step === 4) { // Card details are collected by Stripe's embedded form at the final step — // nothing card-related to validate here. if (state.payment_method === "paypal" && !state.paypal_email) return alert("Add a PayPal email to continue."); if (!state.billing_address || !state.billing_postal) return alert("Add your billing address — Stripe needs it for sales tax."); } if (state.step === 5) { if (!state.consent_disclosure) return showErr("#step4-err", "Tick the publisher-account box to continue — we can't run agents without your okay."); if (!state.consent_ai_disclosure) return showErr("#step4-err", "Tick the FTC AI-disclosure box to continue."); if (!state.ack_refund_policy) return showErr("#step4-err", "Tick the refund-policy box to continue."); } if (state.step === 6) { if (!state.industry) return alert("Pick the closest industry match."); const _indText = (state.industry === "Other (describe)" ? (state.industry_other || "") : state.industry).toLowerCase(); const _banned = ["crypto","web3","nft","defi"," ico","supplement","nutraceutical","weight loss","firearm","weapon","ammo","adult","porn","escort","tobacco","vaping","vape","nicotine","mlm","multi-level","multilevel","pyramid scheme"].some(w => _indText.includes(w)); if (_banned) return alert("We're sorry — Toutmark can't take on customers in this industry.\n\nFor compliance reasons we don't serve crypto/web3, supplements, firearms, adult, tobacco/vaping, or MLM businesses. Regulated industries like finance, legal, healthcare, and insurance ARE welcome (on our Scale plan).\n\nIf you believe this is a mistake, email hello@toutmark.com."); state.is_regulated = REGULATED_INDUSTRIES.has(state.industry); if (state.is_regulated) state.plan = "scale"; } if (state.step === 7) { const _qs = (state.target_queries || []).filter(q => q && (q.query || "").trim()); if (_qs.some(q => q.winnability === "checking")) return alert("Still analyzing your queries' winnability — give it a couple seconds, then continue."); const _g = _qs.filter(q => q.winnability === "green").length; const _y = _qs.filter(q => q.winnability === "yellow").length; if (_g < 2 || (_g + _y) < 4) return alert("Pick at least 2 Winnable (Green) queries and 4 Green/Yellow queries total to continue.\n\nGreen = realistic to win in 90 days · Yellow = competitive but doable · Red doesn't count toward the minimum. (4 Greens works too.)\n\nUse “Suggest queries from my site” — it gives you 3 Green and 3 Yellow examples tailored to your business."); } if (state.step === 9) { if (!state.spokesperson_attested) { // Soft skip — handled by skip button; no hard block } if (state.plan === "growth" || state.plan === "scale") { if (!state.press_approver_name || !state.press_approver_title) return alert("Add the press release approver's name and title."); const pm = state.press_approver_contact_method || "email"; if (pm === "email" || pm === "both") { if (!state.press_approver_email) return alert("Add the press release approver's email."); } if (pm === "phone" || pm === "both") { if (!state.press_approver_phone) return alert("Add the press release approver's phone."); } } } if (state.step === 10) { if (state.regulated_mode === null && !state.is_regulated) return alert("Pick yes or no on the regulated-industry question."); if ((state.regulated_mode === "Y" || state.is_regulated)) { if (!state.cco_name || !state.cco_email) return alert("Add your compliance officer's name and email — required for regulated mode."); if (state.is_securities) { if (!state.securities_primary_regulator) return alert("Pick your primary securities regulator."); if (!state.securities_preview_gate_ack) return alert("Your CCO must acknowledge the preview-and-accept gate before we can enable securities mode."); } } } if (state.step === 11) { // Auto-approve step — no hard validation; all selections optional + saved live. } if (state.step === 12) { // AI citation bot-readiness — MANDATORY gate (2026-06-13). No self-skip: the // customer cannot create the account / pay until a FRESH probe shows all 5 AI // crawlers can reach the site. The only escape is "Get setup help" (captures // the lead + alerts the team); it does NOT advance. See stepBotReadiness(). const _br = state.bot_readiness_result; if (!_br || _br.severity !== "ok") { alert("All 5 AI crawlers must be able to reach your site before you can continue. Fix the blocks shown and re-run the check — or use “Get setup help” and we’ll help you finish."); return; } } // Create the account from the COMPLETE intake on the last step before Welcome, // then the Welcome step sends the customer to Stripe Checkout to pay + activate. if (state.step === 12) { showWizardBusy("Setting up your account…", ["Creating your workspace…", "Saving your brand and target queries…", "Securing your checkout with Stripe…", "Almost there…"]); await submitSignup(); hideWizardBusy(); if (state.signup_error) { alert("Signup couldn't be completed: " + state.signup_error); return; } clearDraft(); } if (state.step < TOTAL_STEPS) { state.step++; render(); } } bootWizard();