# Aether Events — Exhibitor Leads Module (v3) **Status:** Implemented and ready for demo. Core lead capture flow works end-to-end. **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 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` 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