diff --git a/qawolf-attribution-handler.js b/qawolf-attribution-handler.js new file mode 100644 index 0000000..175ebfa --- /dev/null +++ b/qawolf-attribution-handler.js @@ -0,0 +1,146 @@ +/* + Must be present (in some form) before qawolf-signup-handler.js runs its + submit handler, since that script calls window.qawAttribution.getAttribution() + with no null-check. In practice this isn't a real ordering risk: this file + sets window.qawAttribution synchronously within milliseconds of page load, + long before a user could type an email and click submit. + + No head/early-load requirement despite earlier assumption: document.referrer + is fixed by the browser at navigation time regardless of when JS reads it, + and setIfAbsent() means first-touch data only gets captured once per + session anyway. Mintlify's standard "runs after the page becomes + interactive" timing is sufficient. + + Nothing sensitive in here — no keys, no internal endpoints — confirmed + safe to commit as plain source in the public docs repo. +*/ + +(function () { + const attributionUrlParams = ['fbclid', 'gclid', 'utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term']; + const attributionCookies = ['li_fat_id']; + + const LANDING_PAGE_KEY = 'qaw_attr_landing_page'; + const REFERRER_KEY = 'qaw_attr_referrer'; + function sessionKey(name) { + return 'qaw_attr_' + name; + } + + function setIfAbsent(key, value) { + if (!value) return; + try { + if (sessionStorage.getItem(key)) return; + sessionStorage.setItem(key, value); + } catch { + // sessionStorage unavailable + } + } + + function readSession(key) { + try { + return sessionStorage.getItem(key) || undefined; + } catch { + return undefined; + } + } + + function getQueryParam(name) { + try { + const url = new URL(window.location.href); + return url.searchParams.get(name) || undefined; + } catch { + return undefined; + } + } + + function getCookie(name) { + if (typeof document === 'undefined') return undefined; + try { + const rows = document.cookie.split('; '); + for (let i = 0; i < rows.length; i++) { + if (rows[i].indexOf(name + '=') === 0) { + const value = rows[i].slice(name.length + 1); + return value ? decodeURIComponent(value) : undefined; + } + } + return undefined; + } catch { + return undefined; + } + } + + function initializeAttribution() { + if (typeof window === 'undefined') return; + setIfAbsent(LANDING_PAGE_KEY, window.location.href); + setIfAbsent(REFERRER_KEY, document.referrer); + attributionUrlParams.forEach(function (name) { + setIfAbsent(sessionKey(name), getQueryParam(name)); + }); + attributionCookies.forEach(function (name) { + setIfAbsent(sessionKey(name), getCookie(name)); + }); + } + + function getStoredAttribution() { + if (typeof window === 'undefined') return {}; + const params = { + landing_page: readSession(LANDING_PAGE_KEY), + referrer: readSession(REFERRER_KEY), + }; + attributionUrlParams.forEach(function (name) { + params[name] = readSession(sessionKey(name)); + }); + attributionCookies.forEach(function (name) { + const live = getCookie(name); + params[name] = live != null ? live : readSession(sessionKey(name)); + }); + const output = {}; + Object.keys(params).forEach(function (key) { + if (params[key]) output[key] = params[key]; + }); + return output; + } + + function getPosthogAttribution() { + if (typeof window === 'undefined') return {}; + var posthog = window.posthog; + if (!posthog || typeof posthog.get_property !== 'function' || typeof posthog.getSessionProperty !== 'function') return {}; + + try { + const clientSessionProps = posthog.get_property('$client_session_props'); + const cspProps = (clientSessionProps ? clientSessionProps.props : {}) || {}; + const merged = {}; + attributionUrlParams.concat(attributionCookies).forEach(function (key) { + merged[key] = posthog.getSessionProperty(key); + }); + merged.landing_page = cspProps.u; + merged.referrer = cspProps.r; + const output = {}; + Object.keys(merged).forEach(function (key) { + if (merged[key]) output[key] = merged[key]; + }); + return output; + } catch { + return {}; + } + } + + function getAttribution() { + const stored = getStoredAttribution(); + const posthog = getPosthogAttribution(); + + const output = {}; + Object.keys(stored).forEach(function (k) { + output[k] = stored[k]; + }); + Object.keys(posthog).forEach(function (k) { + output[k] = posthog[k]; + }); + if (typeof window !== 'undefined' && window.location && window.location.href) { + output.submitted_from_url = window.location.href; + } + return output; + } + + initializeAttribution(); + window.qawAttribution = { getAttribution }; +})(); diff --git a/qawolf-signup-handler.js b/qawolf-signup-handler.js new file mode 100644 index 0000000..3a7a3c0 --- /dev/null +++ b/qawolf-signup-handler.js @@ -0,0 +1,243 @@ +/* + Verbatim handler from the design team, unmodified. + + Uses the default platformUrl ('https://app.qawolf.com') since + document.currentScript.dataset won't be set when Mintlify includes this + as an external file rather than an inline tag with data-platform-url — the + fallback in the code already covers this, no changes needed here. + + Requires: + 1. CORS on app.qawolf.com's /api/trpc/* allows the docs origin. + 2. window.qawAttribution is loaded (or safely stubbed) on the docs domain. + Until then every submission fails with the same generic "Failed to + connect to the server" message, which looks identical regardless of which + of the two is actually missing. +*/ + +function getPosthogIds() { + const posthog = window?.posthog; + if (!posthog) return {}; + const posthogDistinctId = posthog.get_distinct_id(); + const posthogSessionId = posthog.get_session_id(); + return { + posthogDistinctId, + posthogSessionId, + }; +} + +const platformUrl = document.currentScript?.dataset.platformUrl || 'https://app.qawolf.com'; +const requestTimeoutMs = 5000; +const genericError = 'Something went wrong. Please try again.'; +const invalidEmail = 'Please enter a valid email address.'; + +const boundForms = new WeakSet(); + +function renderFatalBanner(message) { + const banner = document.createElement('div'); + banner.textContent = `Signup handler broken: ${message}`; + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' + 'background:#b00020;color:#fff;padding:12px 16px;' + 'font:14px/1.4 system-ui,sans-serif;text-align:center;'; + document.body.appendChild(banner); +} + +async function postTrpc(procedure, payload, signal) { + const response = await fetch(`${platformUrl}/api/trpc/${procedure}`, { + body: JSON.stringify({ json: payload }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal, + }); + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + return { + type: 'http-error', + status: response.status, + body: errorBody, + message: errorBody?.error?.json?.message, + }; + } + const body = await response.json(); + const result = body?.result?.data?.json; + if (!result || !result.outcome) { + return { type: 'bad-shape', body }; + } + return { type: 'ok', result }; +} + +function navigate(url) { + window.location.href = url; +} + +// Navigates if maybeUrl is a valid same-origin URL; returns an error message otherwise. +function navigateToSafeUrl(maybeUrl) { + if (typeof maybeUrl !== 'string' || !maybeUrl) return genericError; + let target; + try { + target = new URL(maybeUrl, platformUrl); + } catch (error) { + return genericError; + } + if (target.origin !== new URL(platformUrl).origin) return genericError; + navigate(target.href); + return undefined; +} + +function handleSignUpOutcome(result) { + switch (result.outcome) { + case "code-sent": + case "sso-required": + return navigateToSafeUrl(result.redirectUrl); + case 'proceed-to-signin': + return navigateToSafeUrl(result.signInUrl); + case 'temporarily-disabled': + navigate('/book-a-demo'); + return undefined; + case 'invalid-email': + switch (result.reason) { + case 'malformed': + return invalidEmail; + case 'public-domain': + return 'Please use your work email address.'; + case 'blocked': + return 'Please use a different email address to sign up.'; + } + default: + return genericError; + } +} + +function handleBookADemoOutcome(result) { + switch (result.outcome) { + case 'ok': + return navigateToSafeUrl(result.redirectTo); + case 'invalid-email': + return invalidEmail; + default: + return genericError; + } +} + +function buildPayload(email) { + return { + email, + attribution: window.qawAttribution.getAttribution(), + ...getPosthogIds(), + }; +} + +const FORM_KINDS = [ + { + formSelector: '[data-signup-form]', + emailSelector: '[data-signup-email]', + feedbackSelector: '[data-signup-feedback]', + procedure: 'auth.submitSignUpEmail', + handleOutcome: handleSignUpOutcome, + submitLabel: 'Get started', + }, + { + formSelector: '[data-book-a-demo-form]', + emailSelector: '[data-book-a-demo-email]', + feedbackSelector: '[data-book-a-demo-feedback]', + procedure: 'acquisition.submitBookADemoForm', + handleOutcome: handleBookADemoOutcome, + submitLabel: 'Book a demo', + }, +]; + +function setupForm(form, kind) { + if (boundForms.has(form)) return; + boundForms.add(form); + + const emailInput = form.querySelector(kind.emailSelector); + if (!emailInput) { + renderFatalBanner(`missing ${kind.emailSelector} inside ${kind.formSelector}`); + return; + } + + const feedbackEl = form.querySelector(kind.feedbackSelector); + if (!feedbackEl) { + renderFatalBanner(`missing ${kind.feedbackSelector} inside ${kind.formSelector}`); + return; + } + + const submitButton = form.querySelector('button[type=submit], input[type=submit]'); + if (!submitButton) { + renderFatalBanner(`missing submit button inside ${kind.formSelector}`); + return; + } + + form.addEventListener( + 'submit', + async (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (submitButton.disabled) return; + feedbackEl.textContent = ''; + feedbackEl.style.display = 'none'; + submitButton.disabled = true; + submitButton.classList.add('is-loading'); + submitButton.value = 'Redirecting...'; + + const resetButton = () => { + submitButton.disabled = false; + submitButton.classList.remove('is-loading'); + submitButton.value = kind.submitLabel; + }; + const fail = (message) => { + feedbackEl.textContent = message; + feedbackEl.style.display = 'block'; + resetButton(); + }; + + const email = (emailInput.value || '').trim(); + + try { + const outcome = await postTrpc(kind.procedure, buildPayload(email), AbortSignal.timeout(requestTimeoutMs)); + if (outcome.type === 'http-error' || outcome.type === 'bad-shape') { + fail(genericError); + return; + } + const errorMessage = kind.handleOutcome(outcome.result, email); + if (errorMessage) fail(errorMessage); + } catch (error) { + if (error?.name === 'TimeoutError') { + fail('Request timed out. Please try again.'); + } else if (error?.name === 'AbortError') { + // Expected on page unload / navigation. Ignore silently. + } else { + fail('Failed to connect to the server. Please try again.'); + } + } + }, + true, + ); + + window.addEventListener('pageshow', () => { + submitButton.disabled = false; + submitButton.classList.remove('is-loading'); + submitButton.value = kind.submitLabel; + }); +} + +function initFormHandlers() { + FORM_KINDS.forEach((kind) => { + document.querySelectorAll(kind.formSelector).forEach((form) => setupForm(form, kind)); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initFormHandlers); +} else { + initFormHandlers(); +} + +// Handle forms (re)created later via Finsweet Attributes Inject. +// Harmless no-op on Mintlify — Finsweet only exists on the Webflow marketing +// site, so this array just sits unused here. Left in since it's verbatim. +window.FinsweetAttributes ||= []; +window.FinsweetAttributes.push([ + 'inject', + async (instances) => { + await Promise.all(instances.map((i) => i.loadingPromise)); + initFormHandlers(); + }, +]); \ No newline at end of file diff --git a/qawolf/quick-start.mdx b/qawolf/quick-start.mdx index 2a55316..eddb190 100644 --- a/qawolf/quick-start.mdx +++ b/qawolf/quick-start.mdx @@ -8,13 +8,16 @@ description: "Connect your application, generate your first flow, and run your f ## Before you begin +import TryForFreeForm from '/snippets/try-for-free-embed.mdx'; + Before getting started, make sure the following are set up: - - **You have access to a QA Wolf workspace.**\ - Your team's QA Wolf administrator must invite you before you can log in and configure environments. - - **QA Wolf can access the application you want to test.**\ - If your application is behind a firewall or private network, a **VPN or network allowlist** must be configured so QA Wolf infrastructure can reach it. + - **You have access to a QA Wolf workspace.**
Create an account below or [Sign in](https://app.qawolf.com/sign-in) to your existing account. + + + + - **QA Wolf can access the application you want to test.**
If your application is behind a firewall or private network, a [**VPN or network allowlist** must be configured](/qawolf/QA-Wolf-s-static-IPv4-29c5b2a994fb81bf8487d085abf9dc4d) so QA Wolf infrastructure can reach it.
## Step 1: Map your application diff --git a/snippets/try-for-free-embed.mdx b/snippets/try-for-free-embed.mdx new file mode 100644 index 0000000..5da938a --- /dev/null +++ b/snippets/try-for-free-embed.mdx @@ -0,0 +1,130 @@ +{/* + "Try for free" sign-up embed for Mintlify docs pages, using the real + production handler (from the designer/engineer), not the Zapier webhook + guessed at earlier — that URL in the homepage's raw HTML turned out to be + vestigial. The actual mechanism is a direct call to QA Wolf's own backend: + POST https://app.qawolf.com/api/trpc/auth.submitSignUpEmail + + SETUP: this file goes in your Mintlify repo at /snippets/try-for-free-form.mdx + (Kirk wants this on 4 pages, so make it a snippet, not 4 copies). + + Then on each target page (Welcome, Quick Start, Mapping AI, Automation AI), + add near the top: + + import TryForFreeForm from '/snippets/try-for-free-form.mdx'; + + ...and drop wherever the component should render. + + BLOCKERS before this can work at all (both confirmed by reading the actual + script, not guessed): + + 1. CORS: this fetches cross-origin from the docs domain to app.qawolf.com. + That only works if app.qawolf.com's API allows the docs origin. It almost + certainly doesn't yet — this is the real engineering ask for Haroon/ + Mateus: add https://docs.qawolf.com to the allowed-origins list for + /api/trpc/*. No credentials/cookies are sent by this call, so they don't + need Access-Control-Allow-Credentials — just the origin added. Until + this is done, every submission will fail client-side with a generic + "Failed to connect to the server" message and no other clue why. + + 2. window.qawAttribution: the handler script calls + window.qawAttribution.getAttribution() with no null-check. If that + global isn't loaded on the docs domain, every submission throws and + falls into the same generic failure message. Ask whoever owns that + script whether it's safe/intended to load on docs.qawolf.com, or get a + no-op stub to use instead. + + Until both of those are resolved, this will look "broken" in exactly the + same unhelpful way (generic connection error) regardless of which one is + actually missing — don't burn time debugging the form markup itself if + submissions are failing; check these two first. + */} + +{/* + Styling below is pulled directly from the live homepage component's + computed styles (measured via devtools on the actual hero form), not + eyeballed from a screenshot: border-radius 10.8px (not a full pill — + the earlier draft over-rounded this), 1.5px black border, 3.6px hard + shadow, 44px height, 18px "GT Flexa" font at regular (400) weight, not + bold, 9.9px/13.5px padding, button fill #0DF2CC. Input is a fixed 300px + wide on the real site (not responsive/flexible), matched here too. + */} + +{/* + IMPORTANT — button element type: the handler script controls the loading + state by setting `submitButton.value` (e.g. to "Redirecting..."). That only + updates visible text on an , not a