feat(leads): allow_tracking gate + icon bug fix + docs update

- QR scanner: after badge loads, blocks add with 'Tracking Opt-Out' warning
  card if allow_tracking !== true; replaced deprecated CheckCircle → CircleCheck
- Manual search: shows ShieldOff 'Opt-Out' label per row for blocked badges;
  add_as_lead() also guards against programmatic bypass
- Fix: ae_comp__exhibit_tracking_obj_li — Loader2 from wrong package
  @lucide/svelte → LoaderCircle from lucide-svelte
- ae_types.ts: added allow_tracking and agree_to_tc to ae_EventBadge interface
- README.md (leads): full rewrite reflecting actual current state and known gaps
- TODO__Agents.md: updated Leads entry from stale 'NEXT MAJOR FEATURE' to
  accurate in-progress status with remaining checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-16 15:59:26 -04:00
parent b8104b3ff2
commit be24cfdeb5
6 changed files with 203 additions and 79 deletions

View File

@@ -502,6 +502,10 @@ export interface ae_EventBadge extends ae_BaseObj {
print_first_datetime?: string | Date | null;
print_last_datetime?: string | Date | null;
// Exhibitor lead capture opt-in. Must be true to allow adding as a lead.
allow_tracking?: boolean | null;
agree_to_tc?: boolean | null;
ticket_list?: any[] | null;
data_json?: any;
default_qry_str?: string | null;

View File

@@ -1,71 +1,126 @@
# Aether (AE) Event Lead Retrieval Module (v3)
# Aether Exhibitor Leads Module (v3)
**Status:** Substantially implemented. Core lead capture flow works end-to-end.
**PWA only** — no Electron involvement. The Electron app is exclusively for the Launcher.
Spec docs:
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` — overview
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3_detail.md` — tab-level detail
---
## Overview
This is a major work in progress. It still needs the overall directory and file structure planned, documented, and created.
Exhibitors sign in at their booth page, then scan or search attendee badges to capture leads.
Leads are stored as `event_exhibit_tracking` records linked to the exhibit and the badge.
All data is cached in IndexedDB (Dexie.js) for offline use, with background API revalidation (SWR).
This module provides a comprehensive solution for event exhibitors to capture and manage leads. It supports exhibitor login, badge scanning for lead capture, manual lead entry, lead qualification (ranking/priority), and data export functionalities. It integrates with the Aether API for data synchronization and utilizes local caching for performance and offline capabilities.
---
## Key Features
## Route Structure
- **Exhibitor Login:** Secure access for exhibitors using a shared passcode or individual license keys (email-based).
- **Lead Capture:** Efficiently add new leads by scanning attendee badge QR codes or through manual data entry.
- **Lead Management:** A dedicated interface for exhibitors to view, sort, and manage their collected leads.
- **Lead Qualification:** Tools to rank and prioritize leads (e.g., sort, star/favorite) for follow-up.
- **Data Export:** Functionality to export collected lead data, typically to an Excel-compatible format.
- **Real-time Synchronization:** Data is synchronized with the backend Aether API.
- **Local Caching:** Utilizes IndexedDB (Dexie.js) for robust local data storage, enabling offline use and faster load times.
```text
/events/[event_id]/leads/ — Exhibit search / landing
/events/[event_id]/leads/exhibit/[exhibit_id]/ — Exhibit detail (4 tabs)
/events/[event_id]/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ — Lead detail
```
## Data Model
---
The core data structures managed by this module are `Exhibit` and `Exhibit_tracking`. The actual DB table name is event_person_tracking. It was originally intended for generalized "tracking" of a person at an event. This would include other things like session attendance tracking, in addition to exhibitor lead retrieval. However, the initial implementation is focused solely on the lead retrieval use case for exhibitors.
### Exhibit
Represents an exhibitor's presence at an event. It contains configuration for lead retrieval, such as:
- `event_exhibit_id`: Unique identifier for the exhibit (primary key).
- `code`, `name`, `description`: Basic information about the exhibit.
- `staff_passcode`: For staff login.
- `leads_api_access`: Boolean indicating if lead retrieval API access is enabled.
- `leads_custom_questions_json`: Configuration for custom questions asked during lead capture.
- `license_max`, `license_li_json`: Details regarding lead retrieval licenses.
### Exhibit_tracking
Represents a single lead captured by an exhibitor. It links an exhibitor to an attendee (badge) and stores details about the interaction:
- `event_exhibit_tracking_id`: Unique identifier for the captured lead (primary key).
- `event_exhibit_id`: Links to the `Exhibit` that captured the lead.
- `event_badge_id`: Links to the attendee's `Badge` information.
- `exhibitor_notes`: Notes added by the exhibitor about the lead.
- `responses_json`: Responses to custom questions.
- Contains duplicated badge information for convenience (e.g., `event_badge_full_name`, `event_badge_email`, `event_badge_professional_title`, `event_badge_affiliations`) to denormalize data for faster display.
## Routing and Components
## Key Files
### Routes
- `/events/[event_id]/(leads)`: The main entry point for the Leads module within a specific event, typically displays a list of available exhibits.
- `+page.svelte`: Renders the list of exhibits.
- `+page.ts`: Loads the data for available exhibits using `events_func.load_ae_obj_li__event_exhibit`.
- `+layout.svelte`/`+layout.ts`: Provides a common layout and data for the module, including a submenu.
- `/events/[event_id]/(leads)/exhibit/[slug]`: Dynamic route for managing leads for a specific exhibitor within an event. The `[slug]` corresponds to `event_exhibit_id`.
- `+page.svelte`: The primary interface for an exhibitor, orchestrating lead capture and management components.
- `+page.ts`: Loads specific `Exhibit` data and associated `Exhibit_tracking` (leads) using `events_func.load_ae_obj_id__event_exhibit` and `events_func.load_ae_obj_li__event_exhibit_tracking`.
| File | Role |
| --- | --- |
| `leads/+page.svelte` | Exhibit search/landing — find your booth |
| `leads/+page.ts` | Layout data load |
| `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs |
| `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Exhibit layout / data load |
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail view/edit |
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.ts` | Lead data load |
### Core Components (within `src/routes/events/[event_id]/(leads)/exhibit/[slug]/`)
### Components (within `exhibit/[exhibit_id]/`)
- `leads_add_scan.svelte`: Handles the process of adding new leads, either by scanning QR codes (badge) or manual entry.
- `leads_list.svelte`: Displays a sortable and filterable list of captured leads for the current exhibitor.
- `leads_manage.svelte`: Provides functionalities for managing individual leads, potentially including editing notes or qualification status.
- `leads_payment.svelte`: (If applicable) Component related to handling payment or license activation for lead retrieval features.
- `leads_view_lead.svelte`: Displays detailed information for a selected lead.
| File | Role |
| --- | --- |
| `ae_tab__start.svelte` | Tab 1 — welcome + sign-in |
| `ae_tab__add.svelte` | Tab 2 — QR/search toggle + scan mode toggle |
| `ae_tab__manage.svelte` | Tab 4 — admin tools, booth config, app settings |
| `ae_comp__exhibit_signin.svelte` | Sign-in: shared passcode + licensed user |
| `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid vs. qualify mode) |
| `ae_comp__lead_manual_search.svelte` | Manual badge search + add |
| `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter bar |
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
| `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) |
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) |
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional |
| `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page |
## Technical Implementation
### Lead detail components (within `lead/[exhibit_tracking_id]/`)
- **Svelte v5 with Runes:** The module adheres to Svelte v5's reactivity model, utilizing `$state` and `$derived` for reactive state management.
- **ID Convention (`id`):** All frontend operations, including routing and data fetching, consistently use the string-based `event_exhibit_id` and `event_exhibit_tracking_id` as primary identifiers, rather than numeric `id` values.
- **API Interaction:** Data is fetched and synchronized with the backend FastAPI application through functions exposed in `src/lib/ae_events_functions.ts`.
- **Local Database (Dexie.js):** Data for `Exhibit` and `Exhibit_tracking` is cached in the browser's IndexedDB using Dexie.js, defined in `src/lib/ae_events/db_events.ts`. This ensures data persistence and fast retrieval, especially for offline scenarios.
- **Styling:** The UI is primarily styled using Tailwind CSS, having migrated from Skeleton UI to resolve previous rendering issues.
| File | Role |
| --- | --- |
| `ae_comp__lead_detail_form.svelte` | Custom question response editor |
---
## Data Model
### `event_exhibit`
Represents one exhibitor's presence at an event.
Key fields: `event_exhibit_id`, `name`, `code` (booth #), `staff_passcode`, `priority` (paid flag),
`license_max`, `license_li_json` (array of `{full_name, email, passcode}`),
`leads_custom_questions_json` (array of question defs), `leads_device_sm_qty`, `leads_device_lg_qty`.
### `event_exhibit_tracking`
One captured lead — links an exhibit to a badge.
Key fields: `event_exhibit_tracking_id`, `event_exhibit_id`, `event_badge_id`,
`external_person_id` (capturer's email), `exhibitor_notes` (HTML),
`responses_json` (`{ [question_code]: { response: value } }`),
`priority`, `enable`, `hide`.
Denormalized badge fields: `event_badge_full_name`, `event_badge_email`,
`event_badge_affiliations`, `event_badge_professional_title`.
---
## Sign-In Model
Three auth levels in this module:
1. **Aether platform auth** (manager_access / trusted_access) — full admin bypass
2. **Shared exhibit passcode** (`event_exhibit.staff_passcode`) — grants booth management access
3. **Licensed user** (email + passcode from `license_li_json`) — grants lead capture access
Auth state stored in `$events_loc.leads.auth_exhibit_kv[exhibit_id]` (persisted to localStorage).
---
## QR Scan Flow
Badge QR encodes: `{ type: 'event_badge', id: '<id_random>' }`
Scanner reads this, checks for duplicate in IDB, loads badge info, then creates an
`event_exhibit_tracking` record via `events_func.create_ae_obj__exhibit_tracking`.
Two scan modes (toggled per exhibit):
- **Rapid** — auto-resets after 2 seconds to scan the next person
- **Qualify** — navigates to lead detail immediately to fill in notes/responses
---
## Known Gaps / Not Yet Implemented
- **`allow_tracking` gate** — spec says badge must have `allow_tracking = true` before a lead can
be added. Neither the QR scanner nor manual search enforce this yet.
- **Payment / Stripe** — `ae_comp__exhibit_payment.svelte` is a stub. The payment tab can be
hidden via the "Show Payment Tab" toggle in the Manage tab's App Settings.
- **Export endpoint** — `download_export__event_exhibit_tracking` calls a legacy V1 endpoint
(`/event/exhibit/{id}/tracking/export`). Verify it's live on the backend before demoing export.
- **PWA install prompt** — spec calls for nudging exhibitors to install as a PWA on the Start tab.
---
## Lib Functions
`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 are aggregated into `events_func` via `src/lib/ae_events_functions.ts`.

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
interface Props {
lq__event_exhibit_tracking_obj_li: any;
log_lvl?: number;
@@ -14,7 +12,8 @@
MapPin,
Clock,
FileText,
ChevronRight
ChevronRight,
LoaderCircle
} from 'lucide-svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { page } from '$app/state';
@@ -66,7 +65,7 @@
<div class="ae_comp__exhibit_tracking_obj_li w-full px-2 sm:px-4">
{#if !lq__event_exhibit_tracking_obj_li}
<div class="flex justify-center p-10">
<Loader2 size="2rem" class="animate-spin opacity-20" aria-hidden="true" />
<LoaderCircle size="2rem" class="animate-spin opacity-20" aria-hidden="true" />
</div>
{:else if lq__event_exhibit_tracking_obj_li.length === 0}
<div class="card p-8 text-center preset-tonal-surface">

View File

@@ -9,7 +9,7 @@
import { ae_api } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events_functions';
import { Search, UserPlus, CheckCircle, LoaderCircle, Eye } from 'lucide-svelte';
import { Search, UserPlus, LoaderCircle, Eye, ShieldOff } from 'lucide-svelte';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { ae_util } from '$lib/ae_utils/ae_utils';
@@ -73,6 +73,15 @@
console.warn('[add_as_lead] badge missing event_badge_id_random and event_badge_id', badge);
return;
}
// Gate: attendee must have opted in to lead tracking (allow_tracking must be explicitly true).
// Defensive guard — the UI already hides the Add button for blocked badges,
// but this prevents any direct/programmatic calls from bypassing the check.
if (badge.allow_tracking !== true) {
console.warn('[add_as_lead] blocked — allow_tracking is not true for badge', badge_id);
return;
}
adding_id = badge_id;
add_error_id = '';
@@ -156,6 +165,12 @@
<Eye size="1em" class="mr-1" />
View
</a>
{:else if badge.allow_tracking !== true}
<!-- Attendee has not opted in to tracking — cannot add as lead -->
<span class="flex items-center gap-1 text-xs text-warning-500 font-bold opacity-70" title="This attendee has not opted in to exhibitor lead tracking.">
<ShieldOff size="1em" />
Opt-Out
</span>
{:else if add_error_id === badge_id}
<span class="text-xs text-error-500 font-bold">Add failed — retry?
<button type="button" class="btn btn-sm preset-outlined-error ml-1" onclick={() => add_as_lead(badge)}>

View File

@@ -16,7 +16,7 @@
import { events_func } from '$lib/ae_events_functions';
import Element_qr_scanner_v3 from '$lib/element_qr_scanner_v3.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { LoaderCircle, UserPlus, CheckCircle, CircleAlert, Eye } from 'lucide-svelte';
import { LoaderCircle, UserPlus, CircleCheck, CircleAlert, Eye, ShieldOff } from 'lucide-svelte';
import type { ae_EventBadge } from '$lib/types/ae_types';
interface Props {
@@ -45,7 +45,7 @@
);
let start_qr_scanner = $state(true);
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked
let found_badge: ae_EventBadge | null = $state(null);
let existing_tracking_id = $state('');
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link
@@ -66,13 +66,20 @@
scanning_status = 'found';
}
// Load full badge info
// Load full badge info (needed for allow_tracking check and display)
try {
found_badge = await events_func.load_ae_obj_id__event_badge({
api_cfg: $ae_api,
event_badge_id: obj.id,
log_lvl: 1
});
// Gate: attendee must have opted in to lead tracking.
// allow_tracking must be explicitly true — default on badges is false (opt-in model).
// Only applies to the 'found' state; already-captured badges are left as-is.
if (scanning_status === 'found' && found_badge?.allow_tracking !== true) {
scanning_status = 'tracking_blocked';
}
} catch (e) {
console.error('Failed to load badge info', e);
}
@@ -139,10 +146,30 @@
</div>
<p class="text-center opacity-70 italic text-sm">Point camera at the badge QR code</p>
{:else if scanning_status === 'tracking_blocked'}
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-warning shadow-xl border-2 border-warning-500 animate-in zoom-in">
<div class="text-center space-y-2">
<ShieldOff size="3em" class="mx-auto text-warning-500" />
<h3 class="h3 font-bold">Tracking Opt-Out</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
<p class="opacity-70 text-sm">
This attendee has not opted in to exhibitor lead tracking
(<code>allow_tracking</code> is not set on their badge).
</p>
</div>
<button
type="button"
class="btn w-full preset-filled-warning font-bold"
onclick={reset_scanner}
>
Scan Next
</button>
</div>
{:else if scanning_status === 'already_added'}
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-secondary shadow-xl border-2 border-secondary-500 animate-in zoom-in">
<div class="text-center space-y-2">
<CheckCircle size="3em" class="mx-auto text-secondary-500" />
<CircleCheck size="3em" class="mx-auto text-secondary-500" />
<h3 class="h3 font-bold">Already Captured</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
<p class="opacity-70 text-sm">This attendee is already in your leads list.</p>
@@ -199,7 +226,7 @@
{:else if scanning_status === 'success'}
<div class="card p-10 w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-success shadow-xl">
<CheckCircle size="4em" class="text-success-500 animate-bounce" />
<CircleCheck size="4em" class="text-success-500 animate-bounce" />
<div class="text-center">
<h3 class="h4 font-bold">Lead Added!</h3>
<p class="text-xl font-bold">{found_badge?.full_name}</p>