# AxAssist SDK — Integration Guide for Lovable Funnels

A step-by-step playbook for wiring the AxAssist embeddable SDK into any Lovable funnel (React + Vite + TypeScript). Distilled from the production integration on `goodhome.axquotes.com`.

> **Funnel slug.** Every funnel has a unique slug provisioned in AxAssist (e.g. `goodhome`, `goodauto`). Replace `YOUR_FUNNEL_SLUG` below with yours. The slug must match the dashboard exactly — typos silently drop events.

---

## 1. What you're building

By the end you'll be sending three event families to AxAssist:

| Event            | When it fires                 | Purpose                       |
| ---------------- | ----------------------------- | ----------------------------- |
| `step_view`      | User lands on a step          | Funnel drop-off analytics     |
| `step_completed` | User leaves a step (advances) | Per-step field capture        |
| `form_completed` | Final step submitted          | Conversion + lead attribution |

Plus an `axassist:field` DOM event for live-mirroring high-value fields (DOB), a `setSessionMeta({ zip })` call as soon as ZIP is known, and a `notifyLeadSubmitted` beacon on the success page.

---

## 2. Embed the SDK in `index.html`

Add at the end of `<head>` (after GTM/GA/Clarity/TrustedForm/Jornaya):

```html
<!-- AxAssist SDK — order matters: sdk.js before embed.js, no defer on embed -->
<!-- You will need to map your reverse proxy to route these paths to correct file locations -->
<script src="/axassist/sdk.js"></script>
<script
  src="/axassist/embed.js"
  data-axa-mode="messages"
  data-axa-funnel-slug="YOUR_FUNNEL_SLUG"
></script>
```

Use the URLs your AxAssist account manager provides. Do **not** swap script order — `embed.js` calls into `window.AxAssist` defined by `sdk.js`. Do **not** add `defer` to `embed.js`; it needs to be parsed before your app bundle calls `window.AxAssist.init`.

> **Important — embed.js auto-init is commented out.** Current builds of `embed.js` have their bottom-of-file `window.AxAssist.init({})` call commented out. That means *nothing boots the SDK unless your app does it*. Section 5 shows the **Option A** bootstrap that triggers embed's wrapper (gtag mirror + listeners + sdk.init using the `data-axa-*` attrs) and then patches in real identifiers. If your build of embed.js still auto-inits, skip the `sdk.init({})` line in section 5 — you'd double-init.

---

## 3. Create `src/lib/axassist-sdk.ts` (accessor + bootstrap)

This file:

1. Mints `fa_session_id` and `axa_vid` into `localStorage` **before** SDK init (the SDK drops events if `sessionId` is null on init).
2. Persists landing-page attribution (`partner_id`, `source_id`, `evftxn`, full query string) in `fa_session_meta.v2`.
3. Exposes `withAxAssist(fn)` — a queue + 20s poller so callers can fire events before the SDK has finished loading.
4. Exposes `initAxAssistSdk({ funnelSlug, funnelVersion, evftxn })` — the one-time bootstrap.

```ts
// src/lib/axassist-sdk.ts
const SESSION_KEY = "fa_session_id";
const VISITOR_KEY = "axa_vid";
const SESSION_META_KEY = "fa_session_meta.v2";

const uuid = (): string => {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;
    return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
  });
};

export type FieldEntry = { label: string; name: string; value: string };

type AxAssistSdk = {
  init?: (opts: Record<string, unknown>) => void;
  installGtagMirror?: () => void;
  track?: (event: string, payload?: unknown) => void;
  trackStepCompleted?: (
    stepKey: string,
    fields: FieldEntry[],
    opts?: { question?: string; type?: "step_completed" | "form_completed" },
  ) => void;
  setSessionMeta?: (meta: Record<string, unknown>) => void;
  publishSession?: (opts: { externalSessionId?: string | null; visitorId?: string | null }) => void;
  notifyLeadSubmitted?: (leadId: string | null, stepKey?: string) => void;
};

declare global { interface Window { AxAssist?: AxAssistSdk } }

const readStorage = (k: string) => { try { return localStorage.getItem(k); } catch { return null; } };
const readParam = (k: string) => { try { return new URLSearchParams(location.search).get(k); } catch { return null; } };

const ensureAxAssistSession = (): void => {
  try {
    const prior = (() => {
      try { const raw = localStorage.getItem(SESSION_META_KEY); return raw ? JSON.parse(raw) : null; }
      catch { return null; }
    })();

    const sessionId = readStorage(SESSION_KEY) || prior?.session_id || uuid();
    localStorage.setItem(SESSION_KEY, sessionId);

    const visitorId = readStorage(VISITOR_KEY) || prior?.visitor_id
      || `v_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
    localStorage.setItem(VISITOR_KEY, visitorId);

    const params = new URLSearchParams(location.search);
    const attribution_raw: Record<string, string> = {};
    for (const [k, v] of params.entries()) {
      if (k.length <= 64) attribution_raw[k] = v.slice(0, 256);
      if (Object.keys(attribution_raw).length >= 40) break;
    }

    localStorage.setItem(SESSION_META_KEY, JSON.stringify({
      ...(prior || {}),
      session_id: sessionId,
      visitor_id: visitorId,
      landing_url: location.pathname + location.search,
      referrer: document.referrer || prior?.referrer || "",
      evftxn: params.get("evftxn") || prior?.evftxn || null,
      partner_id: params.get("partner_id") || prior?.partner_id || null,
      source_id: params.get("source_id") || prior?.source_id || null,
      attribution_raw: Object.keys(attribution_raw).length ? attribution_raw : prior?.attribution_raw || null,
    }));
  } catch { /* localStorage blocked — SDK calls remain safe no-ops */ }
};

const queue: Array<(sdk: AxAssistSdk) => void> = [];
let pollerId: number | null = null;

export const withAxAssist = (fn: (sdk: AxAssistSdk) => void): void => {
  try {
    const sdk = window.AxAssist;
    if (sdk?.init) { try { fn(sdk); } catch { /* swallow */ } return; }
    queue.push(fn);
    if (pollerId !== null) return;
    let attempts = 0;
    pollerId = window.setInterval(() => {
      attempts += 1;
      const ready = window.AxAssist;
      if (ready?.init) {
        window.clearInterval(pollerId!); pollerId = null;
        while (queue.length) { try { queue.shift()!(ready); } catch { /* swallow */ } }
      } else if (attempts >= 40) {
        window.clearInterval(pollerId!); pollerId = null; queue.length = 0;
      }
    }, 500);
  } catch { /* analytics must never break the app */ }
};

let booted = false;
export const initAxAssistSdk = (opts: {
  funnelSlug: string;
  funnelVersion?: number | null;
  evftxn?: string | null;
}): void => {
  if (booted) return;
  booted = true;
  ensureAxAssistSession();
  withAxAssist((sdk) => {
    sdk.init?.({
      funnelSlug: opts.funnelSlug,
      sessionId: readStorage(SESSION_KEY),
      visitorId: readStorage(VISITOR_KEY),
      partnerId: readParam("partner_id"),
      sourceId: readParam("source_id"),
      funnelVersion: opts.funnelVersion ?? null,
      evftxn: opts.evftxn ?? readParam("evftxn"),
    });
    sdk.installGtagMirror?.();
    sdk.publishSession?.({
      externalSessionId: readStorage(SESSION_KEY),
      visitorId: readStorage(VISITOR_KEY),
    });
  });
};
```

**Why bootstrap before `init`?** AxAssist binds the session id at `init` time. If you call `init({ sessionId: null })` and mint the id later, every subsequent `step_completed` is orphaned and the session never closes.

---

## 4. Create `src/lib/axassist-completion.ts` (field mapper)

One function per event family. The `buildFieldsForStep` switch is the **only** place step keys → form fields are defined; reuse it for `step_view`, `step_completed`, and `form_completed`.

```ts
// src/lib/axassist-completion.ts
import type { FormData } from "@/components/YourForm/types";
import { withAxAssist, type FieldEntry } from "./axassist-sdk";

const str = (v: unknown) => v == null ? "" : String(v);
const addField = (out: FieldEntry[], label: string, name: string, value: unknown) => {
  const s = str(value).trim();
  if (s) out.push({ label, name, value: s });
};

export const buildFieldsForStep = (
  stepKey: string,
  d: FormData,
): { question: string; fields: FieldEntry[] } => {
  const out: FieldEntry[] = [];
  let question = "";
  switch (stepKey) {
    case "zip_code":
      question = "What's your ZIP Code?";
      addField(out, "Zip Code", "zip", d.zipCode);
      break;
    case "date_of_birth":
      question = "How old are you?";
      addField(out, "DOB Month", "dob_month", d.dobMonth);
      addField(out, "DOB Day",   "dob_day",   d.dobDay);
      addField(out, "DOB Year",  "dob_year",  d.dobYear);
      break;
    case "contact":
      question = "How can we reach you?";
      addField(out, "First Name",     "first_name",     d.firstName);
      addField(out, "Last Name",      "last_name",      d.lastName);
      addField(out, "Email",          "email",          d.email);
      addField(out, "Phone",          "phone",          d.phone);
      addField(out, "Street Address", "street_address", d.streetAddress);
      addField(out, "Zip Code",       "zip",            d.zipCode);
      addField(out, "City",           "city",           d.city);
      addField(out, "State",          "state",          d.state);
      break;
    // ...one case per stepKey in your funnel
    default:
      question = stepKey;
  }
  return { question, fields: out };
};

// ─── step_view (once per step per session) ─────────────────────
const VIEWED_KEY = "fa_axassist_viewed_steps";
export const axassistTrackStepView = (stepKey: string, formData: FormData): void => {
  if (!stepKey) return;
  try {
    const set = new Set((sessionStorage.getItem(VIEWED_KEY) || "").split(",").filter(Boolean));
    if (set.has(stepKey)) {
      console.log("Step Key "+stepKey+" has already been sent");
      return;
    }
    set.add(stepKey);
    sessionStorage.setItem(VIEWED_KEY, [...set].join(","));
  } catch { /* fire anyway */ }
  const { question } = buildFieldsForStep(stepKey, formData);
  withAxAssist((sdk) => sdk.track?.("step_view", { step_key: stepKey, question, type: "step_view" }));
};

// ─── step_completed (once per step per session, skip empty) ────
const FIRED_KEY = "fa_axassist_completed_steps";
export const axassistFireStepOnce = (stepKey: string, formData: FormData): void => {
  if (!stepKey) return;
  const { question, fields } = buildFieldsForStep(stepKey, formData);
  if (fields.length === 0) return; // skip pure transition steps
  try {
    const set = new Set((sessionStorage.getItem(FIRED_KEY) || "").split(",").filter(Boolean));
    if (set.has(stepKey)) return;
    set.add(stepKey);
    sessionStorage.setItem(FIRED_KEY, [...set].join(","));
  } catch { /* fire anyway */ }
  withAxAssist((sdk) =>
    sdk.trackStepCompleted?.(stepKey, fields, { question, type: "step_completed" })
  );
};

// ─── setSessionMeta — call as soon as ZIP is known ─────────────
export const axassistSetSessionMeta = (meta: { zip?: string | null }): void => {
  const zip = (meta.zip ?? "").toString().trim();
  if (!zip) return;
  withAxAssist((sdk) => sdk.setSessionMeta?.({ zip }));
};
```

**Why dedupe with `sessionStorage`?** React StrictMode (dev) and back/forward navigation can re-fire effects. Without dedupe you'll see duplicate step beacons in the dashboard.

**Why skip empty fields?** Pure transition steps ("Loading…") would otherwise create empty `step_completed` rows that pollute funnel analytics.

---

## 5. Bootstrap in `src/main.tsx`

```ts
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { initAxAssistSdk } from "@/lib/axassist-sdk";

initAxAssistSdk({ funnelSlug: "YOUR_FUNNEL_SLUG", funnelVersion: null });

createRoot(document.getElementById("root")!).render(<App />);
```

Call this **before** `createRoot` so identifiers exist by the time the first component mounts.

### Inside `initAxAssistSdk` — Option A bootstrap

Because embed.js's auto-init is commented out, your bootstrap must trigger embed's wrapper exactly once, then patch in real identifiers. Do **not** call `sdk.init({ funnelSlug, sessionId, partnerId, ... })` with full params — that bypasses embed.js entirely (no gtag mirror, no listeners, dead `data-axa-*` attrs).

```ts
// inside initAxAssistSdk(...)
ensureAxAssistSession(); // mint fa_session_id + axa_vid + fa_session_meta.v2 FIRST

withAxAssist((sdk) => {
  // 1) Trigger embed.js's wrapped init:
  //    reads data-axa-* attrs, installs gtag mirror + listeners,
  //    then calls the underlying sdk.init internally.
  sdk.init({});

  // 2) Patch in real identifiers AFTER the wrapper runs, so
  //    embed-provided defaults don't blank them out.
  const sessionId = localStorage.getItem("fa_session_id");
  const visitorId = localStorage.getItem("axa_vid");
  const attribution = readAttribution(); // partner_id, source_id from URL or fa_session_meta.v2

  if (sessionId) sdk.setExternalSessionId?.(sessionId);
  if (visitorId) sdk.setVisitorId?.(visitorId);
  sdk.setSessionMeta?.({
    funnel_slug:    opts?.funnelSlug,
    funnel_version: opts?.funnelVersion ?? null,
    partner_id:     attribution.partner_id,
    source_id:      attribution.source_id,
    evftxn:         opts?.evftxn ?? readUrlParam("evftxn"),
  });
  sdk.publishSession?.({ externalSessionId: sessionId, visitorId });
});
```

**Why this works**

| Anti-pattern: direct `sdk.init({ ...full params })` | Option A: `sdk.init({})` then patch |
|---|---|
| Skips embed.js's gtag mirror | gtag mirror installed |
| Skips embed.js's DOM listeners | listeners attached |
| `data-axa-*` attrs are dead config | `data-axa-*` attrs honored |
| App must replicate everything embed does | App only owns identifiers + attribution |

**Ordering rule:** mint `fa_session_id` and `axa_vid` into localStorage **before** calling `sdk.init({})`. Otherwise embed's wrapper publishes a session with null IDs and every downstream event orphans.

---

## 6. Wire `step_view` + `step_completed` in your form

In the form orchestrator (e.g. `src/components/YourForm/index.tsx`):

```tsx
import { axassistFireStepOnce, axassistSetSessionMeta, axassistTrackStepView }
  from "@/lib/axassist-completion";

// Map step number → stable stepKey (must match buildFieldsForStep cases)
const STEP_KEYS: Record<number, string> = {
  1: "zip_code", 2: "property_type", 3: "currently_insured",
  // ...
  10: "loading", 11: "contact",
};

// Fire step_view on every step landing
useEffect(() => {
  const stepKey = STEP_KEYS[currentStep];
  if (stepKey) axassistTrackStepView(stepKey, formDataRef.current);
}, [currentStep]);

// Fire step_completed when user advances (NOT on contact — that becomes form_completed)
const nextStep = useCallback(() => {
  const step = currentStepRef.current;
  const data = formDataRef.current;
  const stepKey = STEP_KEYS[step];
  if (stepKey && stepKey !== "contact") {
    axassistFireStepOnce(stepKey, data);
  }
  // As soon as ZIP is known, propagate it as session meta
  if (step === 1 && data.zipCode) axassistSetSessionMeta({ zip: data.zipCode });
  goToStep(step + 1);
}, [goToStep]);
```

Wrap each rendered step with `data-step-key` so the popup-shown tracker (§8) can detect the active step:

```tsx
<div data-step-key={STEP_KEYS[currentStep] || "unknown"}>
  {stepElement}
</div>
```

---

## 7. Fire `form_completed` in your final step

Inside `handleSubmit` of the contact / final step, **right after** the webhook POST and **before** navigation:

```ts
import { buildFieldsForStep } from "@/lib/axassist-completion";
import { withAxAssist } from "@/lib/axassist-sdk";

// 1) Tell AxAssist a lead was submitted (closes the session, sets ended_at)
withAxAssist((sdk) => sdk.notifyLeadSubmitted?.(null, "contact"));

// 2) Fire the conversion beacon with full field payload + trust tokens
const { question, fields } = buildFieldsForStep("contact", formData);
if (jornayaLeadId)     fields.push({ label: "Jornaya Lead ID",      name: "jornaya_lead_id",       value: jornayaLeadId });
if (trustedFormCertUrl) fields.push({ label: "TrustedForm Cert URL", name: "trusted_form_cert_url", value: trustedFormCertUrl });

withAxAssist((sdk) =>
  sdk.trackStepCompleted?.("contact", fields, { question, type: "form_completed" })
);

// 3) Re-assert ZIP meta in case it changed on the contact step
if (formData.zipCode) withAxAssist((sdk) => sdk.setSessionMeta?.({ zip: formData.zipCode }));

navigate("/thank-you");
```

Order matters: `notifyLeadSubmitted` first (so the session is flagged converted), then the `form_completed` event (carries the fields), then `setSessionMeta` (idempotent).

---

## 8. Live-mirror high-value fields (DOB example)

For fields AxAssist uses for retargeting (DOB, phone, email), dispatch a DOM event the moment the field is valid — don't wait for step advance:

```ts
// In DateOfBirthStep.handleContinue, after validation passes:
window.dispatchEvent(new CustomEvent("axassist:field", {
  detail: {
    field: "date_of_birth",
    label: "Date of birth",
    value: `${dob.mm}/${dob.dd}/${dob.yyyy}`,
    step: "date_of_birth",
  },
}));
```

The embed script listens for `axassist:field` and forwards immediately. This is what powers "we know your birthday is X" nudges on the next step.

---

## 9. Optional: detect popup nudges and mirror to GA

```ts
// src/lib/axassist-tracking.ts
const CARD_ID = "axassist-card";

const detectStepKey = (): string | null => {
  const node = document.querySelector<HTMLElement>("[data-step-key]");
  return node?.dataset.stepKey?.trim() || null;
};

export const initAxAssistTracking = (): void => {
  if (typeof document === "undefined") return;
  const seen = new WeakSet<Element>();
  const onCard = (el: Element) => {
    if (seen.has(el)) return;
    seen.add(el);
    const stepKey = detectStepKey();
    if (!stepKey) return;
    (window as any).gtag?.("event", "axassist_shown", { step_key: stepKey });
  };
  new MutationObserver((records) => {
    for (const rec of records) rec.addedNodes.forEach((n) => {
      if (n.nodeType !== 1) return;
      const el = n as Element;
      if (el.id === CARD_ID) onCard(el);
      const found = el.querySelector?.(`#${CARD_ID}`);
      if (found) onCard(found);
    });
  }).observe(document.body, { childList: true, subtree: true });
};
```

Call `initAxAssistTracking()` once from a top-level `useEffect` in `App.tsx`.

---

## 10. Example beacon payloads

### 10.1 `step_view`

```json
{
  "event": "step_view",
  "step_key": "property_type",
  "question": "What type of property you have?",
  "type": "step_view"
}
```

### 10.2 `step_completed` (DOB)

```json
{
  "stepKey": "date_of_birth",
  "question": "How old are you?",
  "type": "step_completed",
  "fields": [
    { "label": "DOB Month", "name": "dob_month", "value": "06" },
    { "label": "DOB Day",   "name": "dob_day",   "value": "15" },
    { "label": "DOB Year",  "name": "dob_year",  "value": "1985" }
  ]
}
```

### 10.3 `form_completed` (final beacon)

```json
{
  "stepKey": "contact",
  "question": "How can we reach you?",
  "type": "form_completed",
  "fields": [
    { "label": "First Name",          "name": "first_name",          "value": "Jane" },
    { "label": "Last Name",           "name": "last_name",           "value": "Doe" },
    { "label": "Email",               "name": "email",               "value": "jane@example.com" },
    { "label": "Phone",               "name": "phone",               "value": "+15551234567" },
    { "label": "Street Address",      "name": "street_address",      "value": "123 Main St" },
    { "label": "Zip Code",            "name": "zip",                 "value": "30301" },
    { "label": "City",                "name": "city",                "value": "Atlanta" },
    { "label": "State",               "name": "state",               "value": "GA" },
    { "label": "Jornaya Lead ID",     "name": "jornaya_lead_id",     "value": "ABCD-1234-..." },
    { "label": "TrustedForm Cert URL","name": "trusted_form_cert_url","value": "https://cert.trustedform.com/..." }
  ]
}
```

### 10.4 `axassist:field` DOM event

```json
{
  "field": "date_of_birth",
  "label": "Date of birth",
  "value": "06/15/1985",
  "step": "date_of_birth"
}
```

### 10.5 `setSessionMeta`

```json
{ "zip": "30301" }
```

---

## 11. Verification checklist

After deploy, open DevTools → Network, filter `axassist`:

- [ ] `sdk.js` and `embed.js` return **200** on your funnel domain.
- [ ] `window.AxAssist` exposes `init`, `track`, `trackStepCompleted`, `setSessionMeta`, `notifyLeadSubmitted`, `publishSession`.
- [ ] On page load: one ingest beacon with `session_id` populated (not null).
- [ ] Advancing each step fires a `step_view` then a `step_completed` with `funnelSlug: "YOUR_FUNNEL_SLUG"` and non-empty `fields`.
- [ ] Final submit produces a `form_completed` beacon within ~1s.
- [ ] AxAssist dashboard shows the session with `ended_at` set and field payload visible.
- [ ] Refresh mid-funnel: `fa_session_id`, `axa_vid`, `fa_session_meta.v2` persist in `localStorage`; the same session continues.

---

## 12. Common mistakes

| Mistake                                          | Symptom                                      | Fix                                                                                           |
| ------------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `init({ sessionId: null })`                      | Beacons sent but dashboard shows no sessions | Call `ensureAxAssistSession()` **before** `sdk.init`                                          |
| Slug typo / hyphen vs underscore                 | `embed.js` loads but no events ingested      | Match dashboard slug exactly in both the `<script>` tag and `initAxAssistSdk`                 |
| Calling `trackStepCompleted` on transition steps | Empty rows in funnel analytics               | Skip when `fields.length === 0`                                                               |
| Firing `step_completed` for the final step       | Conversion not recorded                      | Final step uses `type: "form_completed"`, not `step_completed`                                |
| Missing `data-step-key` wrapper                  | Popup-shown tracker can't attribute nudges   | Wrap each rendered step with `<div data-step-key={...}>`                                      |
| Re-firing on React StrictMode double-mount       | Duplicate beacons in dashboard               | Dedupe with `sessionStorage` keys (`fa_axassist_viewed_steps`, `fa_axassist_completed_steps`) |
| Throwing in analytics code                       | App crashes for real users                   | Every SDK helper wraps work in try/catch — never propagate errors                             |

---

## 13. Files to create / modify (summary)

```
index.html                                    +2 <script> tags
src/main.tsx                                  +initAxAssistSdk(...) before render
src/App.tsx                                   +initAxAssistTracking() in useEffect (optional)
src/lib/axassist-sdk.ts                       NEW — accessor + bootstrap
src/lib/axassist-completion.ts                NEW — field mapper + step helpers
src/lib/axassist-tracking.ts                  NEW — popup-shown tracker (optional)
src/components/YourForm/index.tsx             +step_view/step_completed wiring
src/components/YourForm/steps/ContactStep.tsx +form_completed beacon
src/components/YourForm/steps/DateOfBirthStep +axassist:field DOM event
```

That's the entire integration. Keep the SDK accessor (`withAxAssist`) as your single boundary to `window.AxAssist` — never call `window.AxAssist` directly from feature code, so the queue/poller stays in charge.

