{
  "slug": "bw-lead-ai",
  "name": "BW Lead Attribution Intelligence",
  "version": "1.7.0",
  "download_url": "https://plugins.bowden.works/wp-content/uploads/plugin-updates/bw-lead-ai-1.7.0.zip",
  "download_hash": "sha256:bbbd3071538e41f8161ce543899290c485ee2c9102a6876f986624a25789b660",
  "download_size": 323168,
  "requires": "6.0",
  "tested": "",
  "requires_php": "7.4",
  "last_updated": "2026-08-07",
  "homepage": "https://plugins.bowden.works/bw-lead-ai/",
  "author": "Bowden Works",
  "description": "Capture traffic source, attribute it to every lead, and understand where your leads are coming from.",
  "changelog": "## [1.7.0] - 2026-08-07\n\n### Added\n- **The Handoff tab is now two tabs: Journeys and Cross-domain.** Retention, whether\n  a journey keeps growing, what a journey stores, and whether a name is kept against\n  it apply to every journey — a site that never hands anything to another domain\n  still has to answer all four. Filing them under \"Handoff\" put general settings\n  behind a feature most sites never switch on. Cross-domain now holds only what is\n  genuinely cross-domain: the modes, the destinations, the token, and the code.\n\n- **A setup wizard for cross-domain handoff, and the code it was never generating.**\n  The Handoff tab presented modes, origins, two datapoint lists, a token parameter\n  and a lifetime all at once, then offered four bare API URLs under labels like\n  \"Claim the data\" — and never produced the one thing the job actually needs: the\n  script to put on the destination site. Users were expected to write that\n  themselves from the endpoints.\n\n  The wizard asks questions with answers a marketer already knows — where the form\n  lives, what should happen, what the form's fields are called, how long to keep a\n  journey — writes every setting, and generates a ready-to-paste snippet. It covers\n  the whole feature, not just the cross-domain half, because sending somebody to\n  another screen for retention mid-flow is how a setup ends up half-finished. That snippet also appears on the\n  settings screen, regenerated from the saved field mapping, so it is always current.\n\n  The generated code avoids CORS preflight entirely: claim is a plain `fetch` with\n  no custom headers, and confirm is a `sendBeacon` with its parameters in the query\n  string. A JSON body would work — the allow-list permits `Content-Type` and\n  `OPTIONS` — but it would make the destination depend on preflight surviving\n  whatever CDN or tag manager sits in front of it. `sendBeacon` is also the only\n  mechanism that reliably survives the page being torn down by a form submit.\n\n- **Its own place in the admin menu.** \"Lead AI\" is now a top-level menu with\n  Settings and Visitor Journeys under it, rather than an entry buried in Settings.\n  It had grown past what a settings submenu can carry.\n\n- **Longer-lived visitor continuity.** A server-set `HttpOnly` first-party cookie\n  (`bwlai_vk`, 400-day cap, re-issued on each visit) anchors a returning visitor,\n  and a `/journey/beacon` endpoint refreshes it on any page view rather than only\n  when a form is filled in. The 400-day cap is a browser ceiling in every major\n  engine, so the cookie must be re-issued to survive — it is.\n\n- **Devices and people are separate.** A new leads table resolves a person from the\n  email on a submission via `HMAC-SHA256(normalized_email, site_pepper)`, and links\n  the device to them. Where a device has already produced a *different* person, it\n  is flagged as shared and attribution stops rather than guessing. Link, never\n  merge; never rewrite past ownership.\n\n- **\"Keep growing\"** (Lead AI → Journeys → How long journeys live). Off, a saved\n  journey is a snapshot of how someone converted. On, it keeps growing as they come\n  back. Retention is counted from the last contact rather than the conversion, so\n  an active lead is not deleted mid-relationship.\n\n### Changed\n- **Statuses are now Held and Saved** (was pending/confirmed), migrated in place.\n\n- **The admin screens were reviewed in a browser and reworked for scanning.**\n  Always-on descriptions became a revealable \"Why?\" toggle (pure CSS, no\n  JavaScript); every tab intro was cut to one sentence; the nine-row Interactions\n  list became a compact table; sections start collapsed and each shows *what it is\n  currently set to* on its closed row, so the page answers \"what is turned on\n  here?\" without opening anything.\n\n- **Journey report timestamps are the site's timezone and date format.** They were\n  rendered with `gmdate()` in the timeline while the header used the site's\n  settings, so the same record showed two different clocks.\n\n### Fixed\n- **A visitor's second trip to the destination silently filled nothing.** Found live\n  on a real deployment: the claim is single-use, and the WordPress-side script keeps\n  one token per browsing session — so the first click-through burned it and every\n  later trip carried a token that could only 404, with no error anywhere. Two-sided\n  fix: the server now refuses to refresh a claimed token (`data_claimed_at IS NULL`)\n  and mints a fresh one instead, and the front end reads the answer it was already\n  getting — same request, it just stopped discarding the response — swapping in the\n  new token and re-decorating.\n\n- **The destination snippet learned everything a live deployment proved necessary.**\n  It hides the rows it fills (they are plumbing, not questions); `?bwlaidebug=true`\n  keeps them visible with a red outline and narrates progress in the console; it\n  dispatches `input`/`change` events so framework-bound forms notice the values; it\n  retries a 404 claim twice to cover the click-vs-page-load race; and it strips the\n  token from the address bar — only once safely stored, so a refresh keeps working.\n  It also now says, in the code and the UI, that it **replaces** earlier Lead AI\n  tags: a leftover older tag claims the details first and leaves the new one blank.\n\n- **Reloading the wizard's final step wiped the field mapping.** A bare GET of the\n  last step ran the save with nothing collected, writing constructor defaults over\n  the real configuration. It now saves only on a posted walk-through; a bookmark or\n  reload of that step just re-renders your code from what is saved.\n\n- **The wizard's three settings could be erased by any other form save.** They have\n  no inputs on any settings tab — the wizard is their only writer — so the\n  sanitizer's default fallback meant a form that failed to pass them through would\n  silently reset them. They now fall back to the stored value.\n\n- **\"Delete all stored journeys\" returned you to the wrong tab** after the split.\n\n- **The journey report was unreachable** under the new top-level menu — following a\n  link from the list returned \"Sorry, you are not allowed to access this page\".\n  `admin.php` resolves a page's capability through its `$submenu` entry, and\n  `remove_submenu_page()` deletes exactly that, so a hidden page under a top-level\n  menu cannot be authorised at all. The report is now a mode of the Visitor\n  Journeys page. Every server-side test called `render_viewer()` directly and so\n  passed throughout; only loading the real URL caught it.\n\n- **All admin CSS and JavaScript silently stopped loading** on the settings page:\n  `enqueue()` compared the hook against a hardcoded `settings_page_…` prefix that\n  became `toplevel_page_…` when the menu moved. It now compares against the hook\n  `add_menu_page()` returns, so it cannot drift again.\n\n- **Admin assets are versioned by file modification time**, not the plugin version.\n  A CSS fix between releases used to sit behind a browser cache keyed to a version\n  that had not moved, so the fix appeared not to have worked.\n\n- **Ragged page edges throughout.** WordPress admin leaves `box-sizing` at\n  `content-box`, so a `max-width` cap landed a padded card ~50px wider than the\n  unpadded block beside it. The report's blocks now share one border-box measure\n  and one right edge.\n\n- **One script now replaces the two tags the old instructions implied.** It claims\n  the data, fills the mapped fields, and reports the submission itself. The submit\n  detection is a capture-phase listener on the document, so it also covers forms\n  added to the page later; a form that submits purely in JavaScript without firing a\n  submit event needs the confirm call from its own success callback, and the wizard\n  says so.\n\n- **Journey-link mode never produced a journey link.** The mode is defined as \"let\n  the destination store a link back to this site\", but nothing asked where that link\n  should go and the generated snippet never wrote one — it only confirmed the token,\n  so the link existed nowhere the destination's own system could see. The wizard now\n  asks for that field first, in both modes, and the snippet writes\n  `…/admin.php?page=bw-lead-ai-journeys&token=…` into it. No API call is involved:\n  it is this site's own address plus the token the browser already holds.\n\n- **The wizard skipped a step that was still needed.** Choosing \"just let me see the\n  journey\" jumped past field mapping entirely — but that mode still needs one field,\n  for the link. Worse, the step list was fixed at four items, so \"The fields\"\n  rendered with a green *completed* tick against a screen that was never shown, while\n  the reader sat on the last step looking for a way forward. The step now always\n  appears and scales: one field in link mode, the full mapping when datapoints are\n  being sent.\n\n- **Settings rows sat flush against the section border** while the prose between\n  them was indented. A collapsible section applied its inset with `padding`, which\n  a table under `border-collapse: collapse` ignores completely and a form control\n  applies to its own text rather than its position. Each element type now gets the\n  inset in the form it actually honours.\n\n- CORS: WordPress core's `rest_send_cors_headers()` echoes any origin, which made\n  the handoff allow-list decorative. It is now removed on `rest_pre_dispatch`.\n\n- `confirm()` is idempotent — it checked `$wpdb->query()`'s return value, which\n  counts *changed* rows, not matched ones."
}
