`;
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) => `
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)
Payment method
${state.payment_method === "card" ? `
No card details yet — you'll enter them in Stripe's secure payment form at the end of signup, right on this site. We never see or store your card number.
` : ""}
${state.payment_method === "paypal" ? `
After you continue, we email you a PayPal billing-agreement link. The subscription activates the moment you approve it.
` : ""}
${state.payment_method === "ach" ? `
We'll email an invoice for your first month within one business day. ACH-billed accounts run on net-15 by default.
` : ""}
Billing address
Stripe uses this for state sales tax. We don't store a tax ID or W-9 — invoiced/ACH customers can email those to billing@toutmark.com.
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 = `
What you're agreeing to:
Toutmark sets up review-platform accounts (G2, Capterra), press distribution accounts
(EIN Presswire, PR.com), and journalist-pitch accounts (HARO, Qwoted) on your behalf using
your business email and brand details. We submit drafts and citations under your brand;
nothing is published without your approval (or your auto-approve setting). Full details:
AI use policy · Privacy.
FTC AI-disclosure acknowledgment.
Drafts published on your site are produced by Toutmark agents (Sonnet-class LLMs) under your brand. The
FTC's AI-endorsement guidance requires disclosure when AI-generated content materially shapes a buyer's
view. Our default schema/footer disclosure reads: "Some content on this page was drafted by AI and
approved by [Brand Name] before publication." You can edit this in Settings → Disclosure
or remove it where the law allows. By proceeding, you acknowledge the disclosure obligation rests with
your brand as publisher.
Refund policy
First-deliverable satisfaction guarantee. If your first paragraph rewrite, first blog draft, or first schema spec (whichever ships first) is unsatisfactory and we can't fix it within 7 days of your feedback, full refund of your first month. To qualify, you must email help@toutmark.com describing the problem — the 7-day fix window starts then. After your first deliverable is accepted, no refunds — but you can cancel any time from Settings → Billing and keep service through the end of the current period.
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.
Brand colors (all optional — we auto-extract from your homepage on first crawl if you skip)
The Webmaster + Creator agents inherit these when generating new pages on your site. Logo + fonts are pulled automatically from your homepage — you don't need to upload anything.
Competitors (${state.competitors.length} found from auto-search — edit, remove, or add more)
We searched your business name + industry + website to find these. The Analyst agent uses this list silently for competitive scans — Toutmark never names them publicly per the no-direct-competitor rule. Live Phase preview: these are placeholder competitors. The Live Phase 8-hour sprint wires /api/onboarding/competitor-search to a real Brave + Perplexity search.
${(state.competitors || []).map((c, i) => `
`).join("")}
`;
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 ? `
G2 + Capterra vendor listings
Citations on G2 and Capterra move the AI citation needle hardest for software companies.
G2 and Capterra both require the vendor (you) to create and verify the listing personally —
they won't let an agency do it on your behalf. That's fine — we'll walk you through it
after signup with step-by-step instructions, screenshots, and the exact info you'll need.
You stay the verified owner; we get added as a team member to draft review responses.
If "Yes — already claimed": after signup head to /app/onboarding/g2 and /app/onboarding/capterra — we'll show you how to add Toutmark as a team member on each listing.
If "No — set up for me": same walkthroughs at /app/onboarding/g2 + /app/onboarding/capterra include the full account-creation steps (~10 minutes each, then both platforms run their own admission review — usually 1–3 business days).
Skip this for now if you'd rather come back to it — you'll see it on your dashboard as an "AI Citation Boost" reminder.
` : "";
// 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 ? `
Google Business Profile (GBP) claim flow
GBP is how your business shows up in Google Maps and the local-pack results. The Reviewer agent
drafts on-tone replies to every new review (no-promises rule enforced) and the Marketer agent
publishes weekly Posts (Updates / Offers / Events) to keep your profile active. We never own
your GBP listing — you stay the verified owner; we get added as a Manager.
${ynRow("has_gbp", "Google Business Profile")}
${gbpUrlInput}
⚡ Fastest way — Connect with Google (30 seconds)
Skip the manual "add as Manager" step. Sign in with the Google account that owns your business listing and we'll get a scoped token — you stay the owner and can revoke anytime from your Google Account permissions page.
Live right now: Performance analytics (impressions, calls, directions, clicks). Pending Google approval: review replies, posts, profile updates — activates when Google approves Toutmark's management API access (currently in review).
Or, prefer the manual route? After signup, head to /app/gbp:
If you said "Yes — already claimed": add gbp-manager@toutmark.com as a Manager on your listing. Google sends an invite, we auto-accept within an hour.
If you said "No — set up for me": Google requires the business owner to create the listing directly (their identity-verification rule), so we walk you through it at /app/onboarding/gbp right after signup. Postcard verification takes 5–14 days.
You won't be billed for GBP features while we're waiting on Google's verification.
Pick one — Webmaster will check this before any plugin, OAuth publish, or paragraph rewrite runs on your site.
${state.domain_verified ? '✓ Domain verified.' : "We'll re-check every 5 minutes after you save. You'll see a green check on the next step once it's good."}
We mirror your site's AI citation surfaces at {your-pick}.toutmark.com for fast GBP embeds + AI engine fetches. Your AI Profile / Firm Profile page lives on your site at the path you pick.
.toutmark.com
Letters / numbers / dashes only. Locked once chosen.
Default: ${profileDefault} (${state.is_regulated ? "regulated default" : "non-regulated default"}). Editor will reject the legacy /ai-profile path for regulated industries.
`;
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 ? `
Press release approver
Press releases (EIN Presswire, PR.com) carry your brand's name to thousands of newsrooms in a single
push. Per Toutmark policy, every release must be approved by a named person at your company before it
goes out — even with full-auto-approve on. Tell us who that approver is.
Can be the same person as the spokesperson above. The Closer agent will route every press draft to this email for explicit approval before the release wire.
Pick the categories of journalist queries we'll match you to. The Marketer agent only drafts pitches inside these categories — keeps the spokesperson's reply queue focused.
${haroCatChips}
You can adjust these any time from Settings → Spokesperson.
` : "";
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_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
` : ""}
Attestation.
I confirm the person above is authorized to be quoted on this brand's behalf. Toutmark may include them
as the spokesperson on outbound HARO replies and journalist pitches. Each pitch is queued for approval
before sending.
${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.` : ""}
Toutmark routes every flagged or borderline draft to a named compliance officer at your firm.
For RIAs / BDs this is your CCO. For healthcare it's your privacy officer. For law firms it's the
responsible attorney. Without this contact, the Compliance Queue can't escalate.
Editor references this as a secondary gate beyond Toutmark defaults. Paste a public link or a Google Doc the CCO email can access.
Used by Editor to verify disclosure / disclaimer language matches your jurisdiction.
Editor blocks any draft that targets states you're not licensed in (e.g., legal solicitation rules, state-securities registration).
${state.regulator_category === "HIPAA" ? `
HIPAA — Business Associate Agreement
Toutmark may incidentally process PHI from intake forms, blog drafts, or review responses. We need a BAA on file before any draft involving patient data routes through our queue. We send a one-page BAA via DocuSign — your privacy officer signs, we counter-sign.
` : ""}
${state.regulator_category === "State Bar" ? `
Privileged information handling
For law firms, Toutmark agents will never include client names, case captions, or any privileged matter in drafts. Editor flags privileged-looking content and routes it to the CCO/responsible attorney.
` : ""}
Default disclaimer language
Editor appends a regulator-appropriate disclaimer to every public draft. Toutmark's defaults match SEC, FINRA, state-bar, HIPAA, and state-board norms. You can override with your own.
SEC / FINRA / state-securities rules apply to your firm, so we collect a few extra fields up front.
Editor's pre-publish gate references these on every draft.
${state.securities_reg_d ? `
` : ""}
Editor + Compliance Queue will reject any draft that mentions these names.
Preview-and-accept gate — every draft involving securities content (paragraph rewrites, schema, blog posts, press releases, social) will surface to your CCO as a visual preview before any execute. Nothing publishes until your CCO clicks Accept on the rendered page.
` : "";
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}
What "regulated mode" turns on:
Every draft routes through the Compliance Queue with disclosure-language checks
Auto-approve is disabled by default (you can override per-feature)
Audit trail logs every approval action
${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 = `
What full-auto-approve does.
With full-auto-approve on, the agents publish drafts directly to your site as soon as
they pass internal QA — no approval queue, no waiting. You can review and revert anything within 30 days.
With it off (default), every draft sits in your queue until you (or a teammate) hit
Approve.
Full-auto-approve shifts editorial liability to you. We recommend leaving it off for at least the first
two weeks while you get a feel for the queue. You can flip it on any time from Settings → Approval.
${state.is_regulated ? `
Regulated mode is on, so this option is hidden — every draft must route through Compliance.` : ""}
${!state.is_regulated ? `
` : ""}
Pages we'll create on your site (consent + approval mode for each)
These are the new pages Toutmark adds to your site as part of your AI citation program. Every page is visible to all visitors (humans and AI crawlers alike — no hidden text, no cloaking). You can opt out of any of them, or change the approval mode (queue vs auto-approve). All editable later from Settings.
${(function() {
const reg = state.is_regulated;
const plan = state.plan || "starter";
const hasComparison = plan === "growth" || plan === "scale";
const pages = [
{ id: "brand_profile", label: "Brand profile / firm profile page", desc: "Single dense page on your brand — the canonical 'who we are' surface.", show: true, default: "queue" },
{ id: "at_a_glance", label: "At-a-Glance page", desc: "Single structured page summarizing your brand, products, category, FAQs. Linked from your llms.txt so ChatGPT and Perplexity preferentially cite it.", show: true, default: "queue" },
{ id: "qa_page", label: "FAQ / Q&A page", desc: "8–12 questions buyers actually ask, with FAQPage schema.", show: true, default: "queue" },
{ id: "industry_education", label: "Industry Education page", desc: "Definitional content (no comparisons, no rankings). Required for regulated customers, optional otherwise.", show: reg, default: "queue" },
{ id: "comparison_page", label: "Comparison & decision-framework page", desc: "How-to-choose-in-your-category page with criteria, trade-offs, who-fits-which. Default format is decision-framework (Google-spam-policy-safe). True ranked listicle format ('Top 10 X') is only produced if your customer authority justifies it — otherwise we publish the comparison version. Non-regulated only.", show: !reg && hasComparison, default: "queue" },
];
return pages.filter(p => p.show).map(p => {
const v = (state.per_page_consent || {})[p.id];
const consent = v && v.consent === false ? false : true; // default: yes, consent
const mode = (v && v.mode) || p.default;
const dis = reg ? "disabled" : "";
return `
${p.label}
${p.desc}
`;
}).join("");
})()}
Per-feature approval mode (optional fine-tuning)
${state.is_regulated ? "Regulated mode forces queue on everything — these toggles are read-only here." : "Set per-feature instead of global. Default is queue. Switch to auto only for features you've reviewed enough to trust."}
How we handle approvals — honestly. Toutmark's agents process a high volume of approvals every day, and we work hard to make every one as good as possible — multiple review gates run before anything is published. But no automated system is perfect, and we will occasionally get one wrong. That's exactly why you decide here what publishes automatically versus what waits in your queue for a human — and why you can review, edit, or revert anything within 30 days.
Securities note: Press always requires CCO approval, regardless of this setting.
` : ""}
Notifications
Where should we ping you when an item lands in your queue or a report is ready?
Reporting cadence
How often should we email the citation report + audit summary?
Daily updates always live in the dashboard regardless of this setting. The monthly Citation Report PDF ships once a month no matter what.
`;
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.
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
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 13All 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…
'; 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 = '
'; }
}
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();