# Aether Events — Exhibitor Leads Module (v3)

**Status:** Implemented and ready for demo. Core lead capture flow works end-to-end.
**Last Updated:** 2026-06-12
**Platform:** PWA only — mobile-first, offline-capable.
**Target users:** Conference exhibitors scanning attendee badges at their booths.

### Recent Changes (2026-04-03)

- Migrated Leads persisted state to Svelte‑5 PersistedState: `leads_loc` now implemented at `src/lib/stores/ae_events_stores__leads.svelte.ts` and the store version constant `AE_LEADS_LOC_VERSION` added to `src/lib/stores/store_versions.ts`.
- Payment UI adjustments: `ae_comp__exhibit_payment.svelte` now accepts a `leads_require_payment` prop and enforces the event-level `mod_exhibits_json.leads_require_payment` flag; a loading guard was added so the component waits for the exhibit record (Dexie `liveQuery`) before deciding which UI to show.
- Tests: update `tests/_helpers/leads_helpers.ts` to seed `leads_loc` defaults and `__version` when needed to avoid localStorage wipe caused by store version checks.

---

## What It Does

The Exhibitor Leads module lets conference exhibitors capture and manage attendee leads directly
from their booth. Exhibitors scan or search attendee badges and build a list of contacts they met.
All data is cached locally (IndexedDB / Dexie.js) for spotty or offline venue Wi-Fi, with
background SWR revalidation against the API when the network is available.

Key capabilities:

- **Badge scanning** — QR scan or text search (name, email, affiliations, badge ID)
- **Lead list** — filterable/sortable, per-exhibitor or per-staff-member view
- **Lead detail** — custom question responses, notes (rich text), priority boolean flag, hide/unhide
- **Export** — CSV/XLSX download of all leads for an exhibit
- **License management** — assign staff accounts (email + passcode) per max license count
- **Custom questions** — configurable per-exhibit follow-up questions (ratings, dropdowns, text)
- **Offline-first** — IndexedDB cache survives network drops; syncs on reconnect
- **PWA install** — Chrome/Android native install prompt; iOS Safari "Add to Home Screen" nudge

---

## Access Levels

Three sign-in levels are supported within this module:

| Level | How to sign in | What they can do |
|---|---|---|
| **Aether Platform Auth** | Standard Aether login (manager/trusted access) | Full admin bypass; all exhibit data |
| **Shared Exhibit Passcode** | Enter booth's `staff_passcode` | Manage licenses, view/add leads |
| **Licensed User** | Email + individual passcode from `license_li_json` | Add and manage leads for this booth |

Auth state is persisted in `$events_loc.leads.auth_exhibit_kv[exhibit_id]` (localStorage-backed).

A booth only shows in the landing page search to non-admins if it is marked `priority = true` (i.e. paid).

### `allow_tracking` Opt-In

Attendees must have `allow_tracking = true` on their badge record to be added as a lead.
Attendees without this flag are blocked at both the QR scanner and the manual search:
- QR scan shows a "Tracking Blocked" warning card (`ShieldOff` icon)
- Manual search shows an "Opt-Out" badge per result row; the "Add as Lead" button is suppressed

---

## Route Structure

```
/events/[event_id]/leads/
    → Exhibit search / landing page — find your booth

/events/[event_id]/leads/exhibit/[exhibit_id]/
    → Main exhibitor view — all 4 tabs

/events/[event_id]/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/
    → Lead detail view — edit notes, custom responses, flags
```

---

## Module Tabs

### Tab 1 — Start / Sign In

The only tab visible when not signed in as a licensed leads user.

- **Sign in with shared passcode** — grants booth management access (license management, passcode change)
- **Sign in as licensed user** — grants lead capture access (email + passcode)
- **PWA install prompt** — Chrome/Android native install button; iOS "Share → Add to Home Screen" instructions
- **License list** — shown when signed in via shared passcode or Aether admin; add/edit/remove staff slots

### Tab 2 — Add Leads

Visible only when signed in (licensed user or Aether auth).

- **Text search** — search by name, email, affiliations, badge ID
- **QR scan** — three modes (persisted per exhibit in `tab_scan_qualify`):
  - **Confirm** (`rapid`) — scan, then choose per badge: **Add & Scan Next** (resets after 2s) or **Add & View Lead** (navigates to detail)
  - **Auto** — no confirmation tap; adds immediately and auto-resets (high-throughput)
  - **Multi** — BarcodeDetector batch scan; up to 4 badges in one frame as a confirm grid
- Previously-removed leads detected on scan — shown a "Previously Removed" card with **Restore & Scan Next** / **Restore & View Lead** buttons
- Results show "Add as Lead" or "View Lead" depending on whether already captured
- `external_person_id` and `group` resolved by auth type — see [Capture Identity](#capture-identity) below

### Tab 3 — Leads List

The main lead management view.

- **Search** — full-text across name, email, notes (local IDB fast path + API revalidation)
- **Sort** — Newest first, Oldest first, Name A→Z, Name Z→A
- **Filter by staff member** — "All Leads" or filter by individual licensed user
- **Show/hide hidden records** — toggles `hide` filter on IDB and API results
- **Export** — downloads CSV/XLSX for the exhibit (`leads_api_access` required)

### Tab 4 — Manage / Config

Exhibit configuration and app settings.

**Admin Tools** (manager_access only):
- Payment status toggle (`priority` boolean field)
- Max licenses, small/large device counts

**Booth Profile** (all signed-in users):
- Exhibitor name, booth description (rich text)

**Access & Security**:
- View/change shared staff passcode
- Sign out button

**Lead Retrieval Config**:
- Exhibit Leads Licensees — manage staff accounts (`administrator_access` OR signed in via shared exhibit passcode)
- Qualifiers & Questions — custom question config
- Licenses & Billing — Stripe payment (only shown when `event.mod_exhibits_json.leads_require_payment = true`)

**App Settings**:
- Auto-hide header/footer toggle
- Show Extra Details toggle
- Refresh interval (1–120 seconds, default 25s), countdown timer, last-refresh timestamp
- Reload App, Clear IDB, Hard Reset (clears localStorage)

---

## Data Model

### `event_exhibit`
One exhibitor's presence at an event.

| Field | Purpose |
|---|---|
| `event_exhibit_id` | Primary / URL-safe ID |
| `name` | Exhibitor display name |
| `code` | Booth number |
| `staff_passcode` | Shared sign-in code |
| `priority` | `1` = paid/active |
| `license_max` | Max licensed staff slots |
| `license_li_json` | Array of `{ full_name, email, passcode }` |
| `leads_custom_questions_json` | Array of question definitions |
| `leads_device_sm_qty` / `leads_device_lg_qty` | Device count tracking |

### `event_exhibit_tracking`
One captured lead — links an exhibit to a badge.

| Field | Purpose |
|---|---|
| `event_exhibit_tracking_id` | Primary key |
| `event_exhibit_id` | Parent exhibit |
| `event_badge_id` | Captured attendee's badge |
| `external_person_id` | Capturing staff's email (from license) |
| `exhibitor_notes` | Rich text notes (HTML via TipTap) |
| `responses_json` | `{ [question_code]: { response: value } }` |
| `priority` | Star/flag for high-priority leads |
| `hide` | Soft-delete / hide from list |
| Denormalized badge fields | `event_badge_full_name`, `event_badge_email`, `event_badge_affiliations`, `event_badge_professional_title` |

---

## Key Files

### Routes

| File | Role |
|---|---|
| `leads/+page.svelte` | Exhibit search/landing |
| `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs |
| `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Layout / data load |
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail |

### Components

| File | Role |
|---|---|
| `ae_tab__start.svelte` | Tab 1 — welcome, sign-in, license list |
| `ae_tab__add.svelte` | Tab 2 — QR scan + text search toggle |
| `ae_tab__manage.svelte` | Tab 4 — admin tools, booth config, app settings |
| `ae_comp__exhibit_signin.svelte` | Sign-in UI (shared passcode + licensed user) |
| `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid / qualify mode) |
| `ae_comp__lead_manual_search.svelte` | Manual badge search + add |
| `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter/sort bar |
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
| `ae_comp__exhibit_license_list.svelte` | License slot manager |
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor |
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder |
| `ae_comp__exhibit_search.svelte` | Exhibit search on the landing page |
| `lead/ae_comp__lead_detail_form.svelte` | Custom question response editor |

### Lib Functions

| File | Role |
|---|---|
| `src/lib/ae_events/ae_events__exhibit.ts` | Exhibit load, search, create, update |
| `src/lib/ae_events/ae_events__exhibit_tracking.ts` | Tracking load, search, create, update, export |

Both aggregated into `events_func` via `src/lib/ae_events/ae_events_functions.ts`.

---

## Offline / PWA Notes

- All data is stored in `db_events` (Dexie.js) — `exhibit` and `exhibit_tracking` tables
- SWR pattern: IDB cache returned immediately; background API fetch updates IDB and triggers UI refresh
- Search: local IDB first pass (fast), then API revalidation via `search__exhibit_tracking`
- `beforeinstallprompt` event captured at module load time (`src/lib/pwa/pwa_install.svelte.ts`)
  — fires within ~1 second of page load, before any Svelte `$effect` runs
- iOS Safari: no native install prompt; shows "Share → Add to Home Screen" instructions instead

---

## Capture Identity

`external_person_id` and `group` on every `event_exhibit_tracking` record record who captured the lead. Resolved at capture time in all three lead capture components (single scanner, multi scanner, manual search):

| Auth type | `kv.type` | Value stored |
| --- | --- | --- |
| Licensed exhibit user | `'licensed'` | Their email address (`kv.key`) |
| Shared exhibit passcode | `'shared'` | `'shared_passcode'` (label — raw passcode is NOT stored) |
| Aether user (admin bypass, no kv) | `undefined` | `$ae_loc.access_type` — e.g. `'trusted'`, `'manager'`, `'super'` |

`kv` = `$events_loc.leads.auth_exhibit_kv[exhibit_id]` (localStorage-persisted exhibit sign-in state).

---

## Lead Soft-Delete / Re-enable

Leads are never hard-deleted. "Remove Lead" sets `enable = false`. Key behaviors:

- **Leads list** always filters out `enable = false` records (both IDB fast-path and API results) — no flash of removed records
- **QR scanner**: if a previously-removed badge is scanned, the scanner detects it via `existing_leads_map` (IDB) or API fallback search (`search__exhibit_tracking` with `qry_badge_id` + `enabled: 'not_enabled'`) and shows the reenable card instead of an error
- **Lead detail page**: "Remove Lead" button (two-click confirm in header) sets `enable = false` and navigates back. "Restore Lead" card appears at the bottom of the right sidebar when `enable` is falsy.
- `search__exhibit_tracking` supports `qry_badge_id` param (added) and `enabled: 'not_enabled'` to find disabled records for a specific badge + exhibit combination

---

## Known Gaps

None currently. See TODO__Agents.md for remaining smoke test items.

## Implemented (previously listed as gaps)

### Payment / Stripe
`ae_comp__exhibit_payment.svelte` is fully implemented. Three states: paid (`priority=true` green
confirmation card), Stripe not configured (admin hint), payment form with license tier selector.
Visibility is event-wide: set `event.mod_exhibits_json.leads_require_payment = true` in the event
settings JSON to enable. When `false` (default), both the header CreditCard button and the
"Licenses & Billing" accordion in the Manage tab are hidden. The Stripe component itself is
unchanged — gating is done in `+page.svelte` and `ae_tab__manage.svelte`.

### License Management — Shared Passcode Access
Implemented. The license section in the Manage tab is visible to Aether admins and to anyone
signed in via the shared exhibit passcode (`auth_exhibit_kv[exhibit_id].type === 'shared'`).

### "My Leads" filter for shared-passcode users
Fixed. `external_person_id` is stored as the literal `'shared_passcode'` for shared users (not
the raw passcode string). The `search_params` derived in `+page.svelte` now checks `kv.type ===
'shared'` and resolves to `'shared_passcode'` instead of `kv.key`, so the "My Leads" filter
correctly returns their captured records.

---

## OSIT Admin Notes

- Mark `priority = 1` on an exhibit to make it visible in public search and to enable lead capture
- `license_max` controls how many licensed staff slots an exhibit can have
- Export endpoint: `GET /v3/action/event_exhibit/{id}/tracking_export` — requires `leads_api_access`
- Custom questions are stored per-exhibit in `leads_custom_questions_json` (not global)
- The exhibitor landing page link format: `/events/[event_id]/leads/exhibit/[exhibit_exhibit_id]/`


## Old Files for Reference

@backups/legacy/events_leads_v2/exhibit/[slug]/+page.svelte
@backups/legacy/events_leads_v2/exhibit/[slug]/leads_manage.svelte
@backups/legacy/events_leads_v2/exhibit/[slug]/leads_payment.svelte