# BW Lead Attribution Intelligence — Architecture

**Version:** 0.5.0 | **Last Updated:** 2026-04-11

## Overview

A client-side-first tracking plugin. Almost all logic runs in the browser against
`localStorage`; the PHP side is limited to a settings admin, merge-tag registration for
Gravity Forms, and a tiny REST endpoint for the UTM builder's (optional) URL autocomplete.

The design choice: there is no server-side per-visit record at all in Phase 1. Every
visitor's state lives only in their own browser until they submit a form, at which point
the capture script substitutes merge tags into hidden fields and lets the form plugin send
everything downstream (Salesforce, HubSpot, whatever). This keeps the WP DB clean and
removes a whole class of privacy / scaling problems at this stage.

Phase 2 (reporting dashboard) will introduce a custom table `wp_bw_lead_ai_submissions`
populated on `gform_after_submission` — but that's out of scope for 0.5.0.

## File Layout

```
bw-lead-ai/
├── bw-lead-ai.php                          Header, constants, boots Plugin class
├── includes/
│   ├── class-bw-lead-ai-plugin.php         Orchestrator (singleton, boot())
│   ├── class-bw-lead-ai-settings.php       Option registration, defaults, sanitization, getters
│   ├── class-bw-lead-ai-frontend.php       Enqueue capture.js, wp_localize_script config, hide-fields CSS
│   ├── class-bw-lead-ai-admin.php          Tabs: Settings / Form Fields / UTM Builder / Test / Help
│   ├── class-bw-lead-ai-merge-tags.php     Gravity Forms merge tag registration
│   └── class-bw-lead-ai-rest.php           /bw-lead-ai/v1/links (manage_options only)
├── assets/
│   ├── css/admin.css
│   └── js/
│       ├── capture.js                      Main front-end capture engine (vanilla)
│       ├── admin-test.js                   Test tab UI
│       └── utm-builder.js                  UTM builder tab
├── vendor/plugin-update-checker/
└── docs/
```

## Resolution Cascade (capture.js)

```
URL params parsed into {key: value} map
        │
        ▼
firstDefined(source_aliases)  ── explicit source?
        │
   ┌────┴────┐
   │ yes     │ no
   │         ▼
   │    findClickId(gclid, fbclid, msclkid, dclid, ...)
   │         │
   │    ┌────┴────┐
   │    │ yes     │ no
   │    │         ▼
   │    │    referrerClass(document.referrer)
   │    │         │
   │    │    ┌────┴────┐
   │    │    │ organic │ social │ referral │ null
   │    │    ▼         ▼         ▼          ▼
   │    ▼    use ref classification         (direct)/(none)
   │    use clickHit.source / .medium
   ▼
use explicit source; medium from firstDefined(medium_aliases) || clickHit?.medium || 'referral'
```

## Storage Keys

localStorage:
- `bw_lai_original` — first visit ever
- `bw_lai_last`     — most recent visit
- `bw_lai_summary`  — `{ visits, pages, taggedVisits, usedPaid }`
- `bw_lai_visits_<ts>` — individual visit records (kept: first 5 + last 5)
- `bw_lai_views_<ts>`  — individual pageview records (kept: first 5 + last 5)

sessionStorage:
- `bw_lai_session` — `"1"` once the visit has been counted

Cookies are used as a fallback when localStorage is unavailable.

## Merge Tags (client-side)

`capture.js` scans every `input` and `textarea` on `DOMContentLoaded`, on any DOM mutation
(for AJAX-loaded forms), and on capture-phase `submit`. For each, if the element's value
contains `{bw:*}`, it substitutes the resolved merge tag value. This lets a Gravity Forms
hidden field with a default of `{bw:source_medium}` work without any server-side hook.

## Hooks

### Actions fired
(none in 0.5.0)

### Filters consumed
- `gform_custom_merge_tags` — registers BW merge tags in the GF UI
- `gform_replace_merge_tags` — passthrough (we leave `{bw:*}` untouched server-side)

## Data Storage

- Option `bw_lead_ai_settings` — full settings array (see `Settings::defaults()`)
- Option `bw_lead_ai_utm_tracking` — UTM builder items

No custom tables, no post meta in 0.5.0.

## External Dependencies

- `vendor/plugin-update-checker` — YahnisElsts plugin update checker v5

## Security Notes

- All REST routes require `manage_options`.
- All admin output is escaped with `esc_html` / `esc_attr` / `esc_textarea` / `esc_url`.
- All option writes go through `sanitize()` / `sanitize_utm()`.
- The hide-tracking-fields `<style>` tag filters each CSS selector through a permissive
  allow-list (`[^a-zA-Z0-9_\-\.\#\[\]\=\"\' :\(\),\*>~\+]` stripped) before output.
- No IP logging in 0.5.0 — privacy-motivated change from legacy behavior that trusted
  `HTTP_CLIENT_IP` / `HTTP_X_FORWARDED_FOR`.
