Prettier for Event Exhibitor Leads

This commit is contained in:
Scott Idem
2026-03-24 12:14:30 -04:00
parent 7f6e286b73
commit 6d1d1e2658
24 changed files with 3116 additions and 2462 deletions

View File

@@ -4,6 +4,7 @@
**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
@@ -31,36 +32,36 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
### Routes
| 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 |
| 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 |
### Components (within `exhibit/[exhibit_id]/`)
| 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 |
| 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 |
### Lead detail components (within `lead/[exhibit_tracking_id]/`)
| File | Role |
| --- | --- |
| File | Role |
| ---------------------------------- | ------------------------------- |
| `ae_comp__lead_detail_form.svelte` | Custom question response editor |
---
@@ -68,12 +69,14 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
## 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),
@@ -87,6 +90,7 @@ Denormalized badge fields: `event_badge_full_name`, `event_badge_email`,
## 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
@@ -102,6 +106,7 @@ Scanner reads this, checks for duplicate in IDB, loads badge info, then creates
`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

View File

@@ -1,11 +1,10 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
// Basic layout for the leads module
let { children }: Props = $props();
// Basic layout for the leads module
</script>
{@render children?.()}

View File

@@ -22,4 +22,4 @@ export async function load({ params, parent }) {
return {
...parent_data
};
}
}

View File

@@ -1,254 +1,258 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import {
events_loc,
events_sess,
events_slct
} from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { LoaderCircle, Store } from '@lucide/svelte';
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
import { onMount, untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import {
events_loc,
events_sess,
events_slct
} from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { LoaderCircle, Store } from '@lucide/svelte';
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
// *** Initialization & Store Guard ***
if ($events_loc.leads) {
if (typeof $events_loc.leads.search_version === 'undefined')
$events_loc.leads.search_version = 0;
if (typeof $events_loc.leads.qry__remote_first === 'undefined')
$events_loc.leads.qry__remote_first = false;
if (typeof $events_loc.leads.qry__search_text === 'undefined')
$events_loc.leads.qry__search_text = '';
if (typeof $events_loc.leads.qry__sort_order === 'undefined')
$events_loc.leads.qry__sort_order = 'name_asc';
}
// *** Initialization & Store Guard ***
if ($events_loc.leads) {
if (typeof $events_loc.leads.search_version === 'undefined')
$events_loc.leads.search_version = 0;
if (typeof $events_loc.leads.qry__remote_first === 'undefined')
$events_loc.leads.qry__remote_first = false;
if (typeof $events_loc.leads.qry__search_text === 'undefined')
$events_loc.leads.qry__search_text = '';
if (typeof $events_loc.leads.qry__sort_order === 'undefined')
$events_loc.leads.qry__sort_order = 'name_asc';
}
let exhibit_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
let exhibit_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
// Stable LiveQuery Pattern
let lq__event_exhibit_obj_li = $derived.by(() => {
const ids = exhibit_id_li;
const event_id = page.params.event_id;
// Stable LiveQuery Pattern
let lq__event_exhibit_obj_li = $derived.by(() => {
const ids = exhibit_id_li;
const event_id = page.params.event_id;
return liveQuery(async () => {
// SCENARIO 1: Specific IDs provided (Search Results)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
// SCENARIO 2: Fallback broad search
if (event_id && !$events_loc.leads.qry__search_text) {
return await db_events.exhibit
.where('event_id')
.equals(event_id)
.sortBy('name');
}
return [];
});
});
// Standardized Reactive Search Pattern
let search_params = $derived({
v: $events_loc.leads.search_version,
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
sort: $events_loc.leads.qry__sort_order,
event_id: page.params.event_id,
remote_first: $events_loc.leads.qry__remote_first
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const event_id = params.event_id;
const remote_first = params.remote_first;
const qry_str = params.str;
if (!event_id) return;
// --- Search Constraint: Min 3 characters for non-trusted users ---
if (!$ae_loc.trusted_access && qry_str.length < 3) {
if (log_lvl) console.log('🛑 [Trace] Search string too short for public user.');
untrack(() => {
exhibit_id_li = [];
$events_sess.leads.submit_status__search = 'idle';
});
return;
return liveQuery(async () => {
// SCENARIO 1: Specific IDs provided (Search Results)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
if (log_lvl) console.log(`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`);
// SCENARIO 2: Fallback broad search
if (event_id && !$events_loc.leads.qry__search_text) {
return await db_events.exhibit
.where('event_id')
.equals(event_id)
.sortBy('name');
}
return [];
});
});
// Standardized Reactive Search Pattern
let search_params = $derived({
v: $events_loc.leads.search_version,
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
sort: $events_loc.leads.qry__sort_order,
event_id: page.params.event_id,
remote_first: $events_loc.leads.qry__remote_first
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
handle_search_refresh(params);
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try {
let local_results = await db_events.exhibit
.where('event_id')
.equals(event_id)
.filter((exhibit) => {
// Priority Filter for Public
if (!$ae_loc.manager_access && !exhibit.priority) return false;
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
if (qry_str) {
const name = (exhibit.name ?? '').toLowerCase();
const code = (exhibit.code ?? '').toLowerCase();
if (
!name.includes(qry_str) &&
!code.includes(qry_str)
)
return false;
} else if (!$ae_loc.trusted_access) {
// Don't show default results to public if no search string
return false;
}
return true;
})
.toArray();
const current_search_id = ++last_search_id;
const event_id = params.event_id;
const remote_first = params.remote_first;
const qry_str = params.str;
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (a.name ?? '').localeCompare(b.name ?? '');
case 'name_desc':
return (b.name ?? '').localeCompare(a.name ?? '');
case 'code_asc':
return (a.code ?? '').localeCompare(b.code ?? '');
case 'code_desc':
return (b.code ?? '').localeCompare(a.code ?? '');
case 'updated_desc':
return (
new Date(b.updated_on || 0).getTime() -
new Date(a.updated_on || 0).getTime()
);
default:
return (a.name ?? '').localeCompare(b.name ?? '');
}
});
if (!event_id) return;
const local_ids = local_results
.map((e) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
// --- Search Constraint: Min 3 characters for non-trusted users ---
if (!$ae_loc.trusted_access && qry_str.length < 3) {
if (log_lvl)
console.log('🛑 [Trace] Search string too short for public user.');
untrack(() => {
exhibit_id_li = [];
$events_sess.leads.submit_status__search = 'idle';
});
return;
}
if (current_search_id === last_search_id) {
if (log_lvl) console.log(`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`);
untrack(() => {
exhibit_id_li = local_ids;
});
}
} catch (e) {
console.warn('Exhibit Local Search failed.', e);
}
}
if (log_lvl)
console.log(
`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`
);
// 2. REVALIDATE: API Request
untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
});
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try {
let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { name: 'ASC' };
break;
case 'name_desc':
order_by_li = { name: 'DESC' };
break;
case 'code_asc':
order_by_li = { code: 'ASC' };
break;
case 'code_desc':
order_by_li = { code: 'DESC' };
break;
case 'updated_desc':
order_by_li = { updated_on: 'DESC' };
break;
default:
order_by_li = { name: 'ASC' };
}
let local_results = await db_events.exhibit
.where('event_id')
.equals(event_id)
.filter((exhibit) => {
// Priority Filter for Public
if (!$ae_loc.manager_access && !exhibit.priority)
return false;
const results = await events_func.search__exhibit({
api_cfg: $ae_api,
event_id: event_id,
fulltext_search_qry_str: qry_str || null,
priority: $ae_loc.manager_access ? 'all' : 'priority',
order_by_li,
limit: 100
if (qry_str) {
const name = (exhibit.name ?? '').toLowerCase();
const code = (exhibit.code ?? '').toLowerCase();
if (!name.includes(qry_str) && !code.includes(qry_str))
return false;
} else if (!$ae_loc.trusted_access) {
// Don't show default results to public if no search string
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (a.name ?? '').localeCompare(b.name ?? '');
case 'name_desc':
return (b.name ?? '').localeCompare(a.name ?? '');
case 'code_asc':
return (a.code ?? '').localeCompare(b.code ?? '');
case 'code_desc':
return (b.code ?? '').localeCompare(a.code ?? '');
case 'updated_desc':
return (
new Date(b.updated_on || 0).getTime() -
new Date(a.updated_on || 0).getTime()
);
default:
return (a.name ?? '').localeCompare(b.name ?? '');
}
});
const local_ids = local_results
.map((e) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
if (current_search_id === last_search_id) {
const api_ids = results
.map((e: any) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
if (log_lvl) console.log(`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`);
if (log_lvl)
console.log(
`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`
);
untrack(() => {
exhibit_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Exhibit revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
exhibit_id_li = local_ids;
});
}
} catch (e) {
console.warn('Exhibit Local Search failed.', e);
}
}
// 2. REVALIDATE: API Request
try {
let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { name: 'ASC' };
break;
case 'name_desc':
order_by_li = { name: 'DESC' };
break;
case 'code_asc':
order_by_li = { code: 'ASC' };
break;
case 'code_desc':
order_by_li = { code: 'DESC' };
break;
case 'updated_desc':
order_by_li = { updated_on: 'DESC' };
break;
default:
order_by_li = { name: 'ASC' };
}
const results = await events_func.search__exhibit({
api_cfg: $ae_api,
event_id: event_id,
fulltext_search_qry_str: qry_str || null,
priority: $ae_loc.manager_access ? 'all' : 'priority',
order_by_li,
limit: 100
});
if (current_search_id === last_search_id) {
const api_ids = results
.map((e: any) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
if (log_lvl)
console.log(
`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
);
untrack(() => {
exhibit_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Exhibit revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
});
}
}
}
</script>
<section
class="ae_events_leads_new h-full w-full flex flex-col items-center space-y-4 p-4"
>
class="ae_events_leads_new flex h-full w-full flex-col items-center space-y-4 p-4">
<h1 class="h2">Exhibitor Leads</h1>
<Comp_exhibit_search event_id={page.params.event_id ?? ''} />
{#if $events_sess.leads.submit_status__search === 'searching' && exhibit_id_li.length === 0}
<div
class="flex flex-col items-center justify-center p-10 opacity-50 text-center"
>
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
class="flex flex-col items-center justify-center p-10 text-center opacity-50">
<LoaderCircle size="3em" class="mx-auto mb-4 animate-spin" />
<p class="text-xl">Searching exhibits...</p>
</div>
{:else if $lq__event_exhibit_obj_li && $lq__event_exhibit_obj_li.length > 0}
<h2 class="h3">Select your exhibit from the list</h2>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-6xl"
>
class="grid w-full max-w-6xl grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each $lq__event_exhibit_obj_li as exhibit_obj (exhibit_obj.event_exhibit_id)}
<!-- Force iframe mode (hides header/footer and passes exhibit_id via URL param) so the exhibit view can optimize for lead capture and hide irrelevant info. -->
<a
href="/events/{page.params
.event_id}/leads/exhibit/{exhibit_obj.event_exhibit_id}?iframe=true"
class="card card-hover p-4 flex flex-col items-center justify-center text-center space-y-2 preset-tonal"
>
class="card card-hover preset-tonal flex flex-col items-center justify-center space-y-2 p-4 text-center">
<Store size="2em" />
<div class="font-bold text-lg">{exhibit_obj.name}</div>
<div class="text-lg font-bold">{exhibit_obj.name}</div>
<div class="badge preset-filled-surface-500">
Booth #{exhibit_obj.code}
</div>
@@ -256,6 +260,6 @@
{/each}
</div>
{:else}
<p class="opacity-50 mt-10">No exhibits found matching your search.</p>
<p class="mt-10 opacity-50">No exhibits found matching your search.</p>
{/if}
</section>

View File

@@ -21,4 +21,4 @@ export async function load({ params, parent }) {
}
return {};
}
}

View File

@@ -1,44 +1,46 @@
<script lang="ts">
interface Props {
event_id: string;
log_lvl?: number;
interface Props {
event_id: string;
log_lvl?: number;
}
let { event_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries
import {
Library,
LoaderCircle,
RemoveFormatting,
Search
} from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
function handle_search_trigger() {
if ($events_loc.leads.search_version === undefined) {
$events_loc.leads.search_version = 0;
}
$events_loc.leads.search_version++;
}
let { event_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries
import { Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
function handle_search_trigger() {
if ($events_loc.leads.search_version === undefined) {
$events_loc.leads.search_version = 0;
}
$events_loc.leads.search_version++;
}
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
</script>
<div
class="ae_group filters_and_search flex flex-col items-center justify-center gap-2 w-full"
>
class="ae_group filters_and_search flex w-full flex-col items-center justify-center gap-2">
<form
onsubmit={prevent_default(() => {
handle_search_trigger();
})}
autocomplete="off"
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full max-w-7xl px-2 md:px-12 py-2 preset-tonal-success rounded-lg shadow-sm"
>
class="search_form preset-tonal-success flex w-full max-w-7xl flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm md:px-12">
<div
class="flex flex-col md:flex-row items-center justify-center gap-1 grow"
>
class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
<input
type="search"
placeholder="Exhibitor name or code..."
@@ -46,20 +48,18 @@
bind:value={$events_loc.leads.qry__search_text}
autocomplete="off"
data-lpignore="true"
class="input text-lg font-mono grow transition-all"
class="input grow font-mono text-lg transition-all"
onkeyup={(e) => {
if (e.key === 'Enter') {
handle_search_trigger();
}
}}
title="Search by name or code. Press Enter."
/>
title="Search by name or code. Press Enter." />
<select
bind:value={$events_loc.leads.qry__sort_order}
onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit"
>
class="select select-sm max-w-fit px-1 text-xs">
<option value="name_asc">Name ASC</option>
<option value="name_desc">Name DESC</option>
<option value="code_asc">Booth # ASC</option>
@@ -71,10 +71,9 @@
<div class="flex flex-row items-center justify-center gap-1">
<button
type="submit"
class="btn btn-lg preset-tonal-success border border-success-500 hover:preset-tonal-success text-2xl font-bold w-48 transition-all"
>
class="btn btn-lg preset-tonal-success border-success-500 hover:preset-tonal-success w-48 border text-2xl font-bold transition-all">
{#if $events_sess.leads.submit_status__search === 'searching'}
<LoaderCircle class="animate-spin mx-1" />
<LoaderCircle class="mx-1 animate-spin" />
{:else}
<Search class="mx-1" />
{/if}
@@ -88,9 +87,8 @@
$events_loc.leads.qry__search_text = '';
handle_search_trigger();
}}
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all"
title="Clear search query"
>
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
title="Clear search query">
<RemoveFormatting size="1.25em" />
<span class="hidden md:inline"> Clear </span>
</button>
@@ -98,19 +96,16 @@
</form>
<div
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 hover:opacity-100 transition-all"
>
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 transition-all hover:opacity-100">
{#if $ae_loc.edit_mode}
<label
class="flex items-center gap-1 cursor-pointer bg-surface-200-800 px-2 py-1 rounded-token text-xs font-semibold"
>
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
<span> Remote First </span>
<input
type="checkbox"
bind:checked={$events_loc.leads.qry__remote_first}
onchange={handle_search_trigger}
class="checkbox checkbox-sm"
/>
class="checkbox checkbox-sm" />
</label>
{/if}
</div>

View File

@@ -1,26 +1,26 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte
* Exhibitor Dashboard Layout.
*/
interface Props {
children?: import('svelte').Snippet;
}
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte
* Exhibitor Dashboard Layout.
*/
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
let { children }: Props = $props();
import { events_slct } from '$lib/stores/ae_events_stores';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { events_slct } from '$lib/stores/ae_events_stores';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!$events_slct.exhibit_id) return null;
return await db_events.exhibit.get($events_slct.exhibit_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!$events_slct.exhibit_id) return null;
return await db_events.exhibit.get($events_slct.exhibit_id);
})
);
</script>
<!-- <div class="exhibit-layout flex flex-col h-full w-full"> -->
{@render children?.()}
{@render children?.()}
<!-- </div> -->

View File

@@ -13,7 +13,7 @@ export async function load({ params, parent }) {
const exhibit_id = params.exhibit_id;
// Sync to store for components
events_slct.update(s => {
events_slct.update((s) => {
s.exhibit_id = exhibit_id;
return s;
});
@@ -34,4 +34,4 @@ export async function load({ params, parent }) {
}
return {};
}
}

View File

@@ -1,402 +1,427 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import {
events_loc,
events_sess,
events_slct
} from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { CreditCard, Download, LayoutGrid, List as ListIcon, LoaderCircle, Plus, Settings } from '@lucide/svelte';
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte';
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
import Tab_add from './ae_tab__add.svelte';
import Tab_start from './ae_tab__start.svelte';
import Tab_manage from './ae_tab__manage.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
import { onMount, untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import {
events_loc,
events_sess,
events_slct
} from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
CreditCard,
Download,
LayoutGrid,
List as ListIcon,
LoaderCircle,
Plus,
Settings
} from '@lucide/svelte';
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte';
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
import Tab_add from './ae_tab__add.svelte';
import Tab_start from './ae_tab__start.svelte';
import Tab_manage from './ae_tab__manage.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
// *** Initialization & Store Guard ***
if ($events_loc.leads) {
if (typeof $events_loc.leads.tracking__search_version === 'undefined')
$events_loc.leads.tracking__search_version = 0;
if (
typeof $events_loc.leads.tracking__qry__remote_first === 'undefined'
)
$events_loc.leads.tracking__qry__remote_first = false;
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
$events_loc.leads.tracking__qry__search_text = '';
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
$events_loc.leads.tracking__qry__sort_order = 'created_desc';
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
$events_loc.leads.refresh_interval_sec = 25;
if (typeof $events_loc.leads.show_hidden === 'undefined')
$events_loc.leads.show_hidden = false;
// *** Initialization & Store Guard ***
if ($events_loc.leads) {
if (typeof $events_loc.leads.tracking__search_version === 'undefined')
$events_loc.leads.tracking__search_version = 0;
if (typeof $events_loc.leads.tracking__qry__remote_first === 'undefined')
$events_loc.leads.tracking__qry__remote_first = false;
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
$events_loc.leads.tracking__qry__search_text = '';
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
$events_loc.leads.tracking__qry__sort_order = 'created_desc';
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
$events_loc.leads.refresh_interval_sec = 25;
if (typeof $events_loc.leads.show_hidden === 'undefined')
$events_loc.leads.show_hidden = false;
}
// --- Sign-In State (Derived) ---
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
let is_signed_in = $derived(
$ae_loc.manager_access ||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
);
// --- Tab State (Sticky via Store) ---
let active_tab = $derived.by(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return 'start';
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
// If signed in but stuck on start tab, go to list
if (is_signed_in && saved_tab === 'start') return 'list';
return saved_tab;
});
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
function set_active_tab(new_tab: string) {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = new_tab;
}
let tracking_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
// --- NEW: Direct Reactive List Pattern ---
let raw_lead_li: any[] = $state([]);
// Final filtered list that the UI actually sees
// Applying the HARD GUARD here ensures that no matter where the data came from
// (API or IDB), it MUST match the selected licensee.
let filtered_lead_li = $derived.by(() => {
const licensee_filter = search_params.licensee_email;
const show_hidden = search_params.show_hidden;
return raw_lead_li.filter((lead) => {
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
if (lead.enable === 0 || lead.enable === false) return false;
// Exclude hidden leads unless show_hidden is toggled on
if (!show_hidden && lead.hide) return false;
if (licensee_filter === 'all') return true;
const capturer = lead.external_person_id || lead.group;
return capturer === licensee_filter;
});
});
// Subscribe to the Lead List
$effect(() => {
const ids = tracking_id_li;
const exhibit_id = page.params.exhibit_id;
const has_search = !!$events_loc.leads.tracking__qry__search_text;
const observable = liveQuery(async () => {
// 1. Specific IDs provided (from API Search or Manual Entry)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit_tracking.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
// 2. Fallback broad search (Initial load or no search text)
if (exhibit_id && !has_search) {
return await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.reverse()
.sortBy('created_on');
}
return [];
});
const subscription = observable.subscribe((res) => {
raw_lead_li = res;
});
return () => subscription.unsubscribe();
});
// Exhibit Info
const lq__exhibit_obj = liveQuery(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id);
});
// Standardized Reactive Search Pattern
let search_params = $derived.by(() => {
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
// Resolve "My Leads" to actual email
if (licensee_email === 'my') {
licensee_email =
$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
?.key || 'all';
}
// --- Sign-In State (Derived) ---
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
let is_signed_in = $derived(
$ae_loc.manager_access ||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
return {
v: $events_loc.leads.tracking__search_version,
str: ($events_loc.leads.tracking__qry__search_text ?? '')
.toLowerCase()
.trim(),
sort: $events_loc.leads.tracking__qry__sort_order,
licensee_email: licensee_email,
exhibit_id: page.params.exhibit_id,
remote_first: $events_loc.leads.tracking__qry__remote_first,
show_hidden: $events_loc.leads.show_hidden ?? false
};
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
// Reset countdown on manual search
$events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// --- Auto-Refresh Timer Logic ---
$effect(() => {
if (!is_signed_in) return;
const interval = setInterval(() => {
untrack(() => {
if ($events_sess.leads.next_refresh_countdown > 0) {
$events_sess.leads.next_refresh_countdown--;
} else {
// Trigger refresh
$events_loc.leads.tracking__search_version++;
$events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
}
});
}, 1000);
return () => clearInterval(interval);
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const exhibit_id = params.exhibit_id;
const remote_first = params.remote_first;
if (!exhibit_id) return;
if (log_lvl)
console.log(
`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`
);
// --- Tab State (Sticky via Store) ---
let active_tab = $derived.by(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return 'start';
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
// If signed in but stuck on start tab, go to list
if (is_signed_in && saved_tab === 'start') return 'list';
return saved_tab;
});
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
function set_active_tab(new_tab: string) {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = new_tab;
}
let tracking_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
// --- NEW: Direct Reactive List Pattern ---
let raw_lead_li: any[] = $state([]);
// Final filtered list that the UI actually sees
// Applying the HARD GUARD here ensures that no matter where the data came from
// (API or IDB), it MUST match the selected licensee.
let filtered_lead_li = $derived.by(() => {
const licensee_filter = search_params.licensee_email;
const show_hidden = search_params.show_hidden;
return raw_lead_li.filter(lead => {
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
if (lead.enable === 0 || lead.enable === false) return false;
// Exclude hidden leads unless show_hidden is toggled on
if (!show_hidden && lead.hide) return false;
if (licensee_filter === 'all') return true;
const capturer = lead.external_person_id || lead.group;
return capturer === licensee_filter;
});
untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
});
// Subscribe to the Lead List
$effect(() => {
const ids = tracking_id_li;
const exhibit_id = page.params.exhibit_id;
const has_search = !!$events_loc.leads.tracking__qry__search_text;
const qry_str = params.str;
const observable = liveQuery(async () => {
// 1. Specific IDs provided (from API Search or Manual Entry)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit_tracking.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
// 2. Fallback broad search (Initial load or no search text)
if (exhibit_id && !has_search) {
return await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.reverse()
.sortBy('created_on');
}
return [];
});
const subscription = observable.subscribe(res => {
raw_lead_li = res;
});
return () => subscription.unsubscribe();
});
// Exhibit Info
const lq__exhibit_obj = liveQuery(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id);
});
// Standardized Reactive Search Pattern
let search_params = $derived.by(() => {
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
// Resolve "My Leads" to actual email
if (licensee_email === 'my') {
licensee_email = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']?.key || 'all';
}
return {
v: $events_loc.leads.tracking__search_version,
str: ($events_loc.leads.tracking__qry__search_text ?? '')
.toLowerCase()
.trim(),
sort: $events_loc.leads.tracking__qry__sort_order,
licensee_email: licensee_email,
exhibit_id: page.params.exhibit_id,
remote_first: $events_loc.leads.tracking__qry__remote_first,
show_hidden: $events_loc.leads.show_hidden ?? false
};
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
// Reset countdown on manual search
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// --- Auto-Refresh Timer Logic ---
$effect(() => {
if (!is_signed_in) return;
const interval = setInterval(() => {
untrack(() => {
if ($events_sess.leads.next_refresh_countdown > 0) {
$events_sess.leads.next_refresh_countdown--;
} else {
// Trigger refresh
$events_loc.leads.tracking__search_version++;
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
}
});
}, 1000);
return () => clearInterval(interval);
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const exhibit_id = params.exhibit_id;
const remote_first = params.remote_first;
if (!exhibit_id) return;
if (log_lvl) console.log(`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`);
untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
});
const qry_str = params.str;
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try {
const target_exhibit_id = exhibit_id;
const target_licensee_email = params.licensee_email;
let local_results = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(target_exhibit_id)
.filter((tracking) => {
// 0. Never include disabled (removed) records — they're soft-deleted
if (!tracking.enable) return false;
// 1. Hide filter — exclude hidden records unless show_hidden is on
if (!params.show_hidden && tracking.hide) return false;
// 2. Licensee Email Filter
if (target_licensee_email !== 'all') {
if (tracking.external_person_id !== target_licensee_email) return false;
}
if (qry_str) {
const name = (
tracking.event_badge_full_name ?? ''
).toLowerCase();
const email = (
tracking.event_badge_email ?? ''
).toLowerCase();
const notes = ae_util.strip_html(tracking.exhibitor_notes ?? '').toLowerCase();
// Guard: Prevent "undefined" from being searched
if (tracking.exhibitor_notes === 'undefined') {
tracking.exhibitor_notes = '';
}
const qry_string = (
tracking.default_qry_str ?? ''
).toLowerCase();
if (
!name.includes(qry_str) &&
!email.includes(qry_str) &&
!notes.includes(qry_str) &&
!qry_string.includes(qry_str)
)
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (
a.event_badge_full_name ?? ''
).localeCompare(b.event_badge_full_name ?? '');
case 'name_desc':
return (
b.event_badge_full_name ?? ''
).localeCompare(a.event_badge_full_name ?? '');
case 'created_asc':
return (
new Date(a.created_on || 0).getTime() -
new Date(b.created_on || 0).getTime()
);
case 'created_desc':
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
default:
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
}
});
const local_ids = local_results
.map((e) =>
String(e.id || e.event_exhibit_tracking_id)
)
.filter(Boolean);
if (current_search_id === last_search_id) {
if (log_lvl) console.log(`✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`);
untrack(() => {
tracking_id_li = local_ids;
});
}
} catch (e) {
console.warn('Exhibit Tracking Local Search failed.', e);
}
}
// 2. REVALIDATE: API Request
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try {
let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { event_badge_full_name: 'ASC' };
break;
case 'name_desc':
order_by_li = { event_badge_full_name: 'DESC' };
break;
case 'created_asc':
order_by_li = { created_on: 'ASC' };
break;
case 'created_desc':
order_by_li = { created_on: 'DESC' };
break;
default:
order_by_li = { created_on: 'DESC' };
}
const target_exhibit_id = exhibit_id;
const target_licensee_email = params.licensee_email;
const q_event_id: string = page.params.event_id ?? '';
const q_exhibit_id: string = exhibit_id ?? '';
const q_licensee_email: string | null = (params.licensee_email !== 'all') ? (params.licensee_email ?? '') : null;
let local_results = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(target_exhibit_id)
.filter((tracking) => {
// 0. Never include disabled (removed) records — they're soft-deleted
if (!tracking.enable) return false;
// 1. Hide filter — exclude hidden records unless show_hidden is on
if (!params.show_hidden && tracking.hide) return false;
const results = await events_func.search__exhibit_tracking({
api_cfg: $ae_api,
event_id: q_event_id,
event_exhibit_id: q_exhibit_id,
fulltext_search_qry_str: qry_str || null,
qry_external_person_id: q_licensee_email,
hidden: params.show_hidden ? 'all' : 'not_hidden',
order_by_li,
limit: 150
// 2. Licensee Email Filter
if (target_licensee_email !== 'all') {
if (
tracking.external_person_id !==
target_licensee_email
)
return false;
}
if (qry_str) {
const name = (
tracking.event_badge_full_name ?? ''
).toLowerCase();
const email = (
tracking.event_badge_email ?? ''
).toLowerCase();
const notes = ae_util
.strip_html(tracking.exhibitor_notes ?? '')
.toLowerCase();
// Guard: Prevent "undefined" from being searched
if (tracking.exhibitor_notes === 'undefined') {
tracking.exhibitor_notes = '';
}
const qry_string = (
tracking.default_qry_str ?? ''
).toLowerCase();
if (
!name.includes(qry_str) &&
!email.includes(qry_str) &&
!notes.includes(qry_str) &&
!qry_string.includes(qry_str)
)
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (a.event_badge_full_name ?? '').localeCompare(
b.event_badge_full_name ?? ''
);
case 'name_desc':
return (b.event_badge_full_name ?? '').localeCompare(
a.event_badge_full_name ?? ''
);
case 'created_asc':
return (
new Date(a.created_on || 0).getTime() -
new Date(b.created_on || 0).getTime()
);
case 'created_desc':
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
default:
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
}
});
const local_ids = local_results
.map((e) => String(e.id || e.event_exhibit_tracking_id))
.filter(Boolean);
if (current_search_id === last_search_id) {
const api_ids = results
.map((e: any) =>
String(e.id || e.event_exhibit_tracking_id)
)
.filter(Boolean);
if (log_lvl) console.log(`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`);
if (log_lvl)
console.log(
`✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`
);
untrack(() => {
tracking_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
$events_sess.leads.last_refresh_time = new Date().toISOString();
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Lead revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
tracking_id_li = local_ids;
});
}
} catch (e) {
console.warn('Exhibit Tracking Local Search failed.', e);
}
}
async function handle_export() {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
// 2. REVALIDATE: API Request
try {
let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { event_badge_full_name: 'ASC' };
break;
case 'name_desc':
order_by_li = { event_badge_full_name: 'DESC' };
break;
case 'created_asc':
order_by_li = { created_on: 'ASC' };
break;
case 'created_desc':
order_by_li = { created_on: 'DESC' };
break;
default:
order_by_li = { created_on: 'DESC' };
}
await events_func.download_export__event_exhibit_tracking({
const q_event_id: string = page.params.event_id ?? '';
const q_exhibit_id: string = exhibit_id ?? '';
const q_licensee_email: string | null =
params.licensee_email !== 'all'
? (params.licensee_email ?? '')
: null;
const results = await events_func.search__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
log_lvl: 1
event_id: q_event_id,
event_exhibit_id: q_exhibit_id,
fulltext_search_qry_str: qry_str || null,
qry_external_person_id: q_licensee_email,
hidden: params.show_hidden ? 'all' : 'not_hidden',
order_by_li,
limit: 150
});
}
function toggle_main_tab() {
if (active_tab === 'add') {
set_active_tab('list');
previous_main_tab = 'list';
} else {
set_active_tab('add');
previous_main_tab = 'add';
if (current_search_id === last_search_id) {
const api_ids = results
.map((e: any) => String(e.id || e.event_exhibit_tracking_id))
.filter(Boolean);
if (log_lvl)
console.log(
`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
);
untrack(() => {
tracking_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
$events_sess.leads.last_refresh_time = new Date().toISOString();
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Lead revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
});
}
}
}
function toggle_manage_tab() {
if (active_tab === 'manage') {
set_active_tab(previous_main_tab);
} else {
set_active_tab('manage');
}
async function handle_export() {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
await events_func.download_export__event_exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
log_lvl: 1
});
}
function toggle_main_tab() {
if (active_tab === 'add') {
set_active_tab('list');
previous_main_tab = 'list';
} else {
set_active_tab('add');
previous_main_tab = 'add';
}
}
function toggle_manage_tab() {
if (active_tab === 'manage') {
set_active_tab(previous_main_tab);
} else {
set_active_tab('manage');
}
}
</script>
<section
class="ae_events_leads_tracking_new h-full w-full flex flex-col items-center overflow-x-hidden"
>
class="ae_events_leads_tracking_new flex h-full w-full flex-col items-center overflow-x-hidden">
<!-- Header -->
<header class="w-full bg-surface-100-900 border-b border-surface-500/20 px-4 py-2 sticky top-0 z-10 flex items-center justify-between gap-4 shadow-sm">
<div class="flex flex-col min-w-0">
<h1 class="text-base sm:text-lg font-bold truncate leading-tight">
<header
class="bg-surface-100-900 border-surface-500/20 sticky top-0 z-10 flex w-full items-center justify-between gap-4 border-b px-4 py-2 shadow-sm">
<div class="flex min-w-0 flex-col">
<h1 class="truncate text-base leading-tight font-bold sm:text-lg">
{$lq__exhibit_obj?.name ?? 'Exhibitor'}
</h1>
<p class="text-[10px] sm:text-xs opacity-60">Booth #{$lq__exhibit_obj?.code ?? '...'}</p>
<p class="text-[10px] opacity-60 sm:text-xs">
Booth #{$lq__exhibit_obj?.code ?? '...'}
</p>
</div>
<div class="flex items-center gap-1 sm:gap-2">
@@ -404,9 +429,8 @@
<!-- Add Lead / Lead List Toggle -->
<button
type="button"
class="btn btn-sm preset-filled-primary font-bold shadow-sm px-2 sm:px-4"
onclick={toggle_main_tab}
>
class="btn btn-sm preset-filled-primary px-2 font-bold shadow-sm sm:px-4"
onclick={toggle_main_tab}>
{#if active_tab === 'add'}
<ListIcon size="1.25em" class="sm:mr-2" />
<span class="hidden sm:inline">Lead List</span>
@@ -420,12 +444,11 @@
{#if $ae_loc.show_leads_payment}
<button
type="button"
class="btn btn-sm transition-colors px-2 sm:px-3"
class="btn btn-sm px-2 transition-colors sm:px-3"
class:preset-filled-success={active_tab === 'payment'}
class:preset-outlined-success={active_tab !== 'payment'}
onclick={() => set_active_tab('payment')}
title="Payment & Upgrades"
>
title="Payment & Upgrades">
<CreditCard size="1.25em" />
</button>
{/if}
@@ -433,67 +456,65 @@
<!-- Manage / Config -->
<button
type="button"
class="btn btn-sm transition-colors px-2 sm:px-3"
class="btn btn-sm px-2 transition-colors sm:px-3"
class:preset-tonal-surface={active_tab === 'manage'}
class:preset-outlined-surface={active_tab !== 'manage'}
onclick={toggle_manage_tab}
title="Manage Exhibit"
>
title="Manage Exhibit">
<Settings size="1.25em" />
</button>
{/if}
</div>
</header>
<!-- Main Content Area - Stable Width -->
<div class="w-full flex-1 flex flex-col items-center">
<div class="w-full px-4 sm:px-6 py-6 space-y-6">
<div class="flex w-full flex-1 flex-col items-center">
<div class="w-full space-y-6 px-4 py-6 sm:px-6">
{#if !is_signed_in}
<div class="w-full max-w-4xl mx-auto">
<div class="mx-auto w-full max-w-4xl">
<Tab_start />
</div>
{:else if active_tab === 'add'}
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
{:else if active_tab === 'payment'}
<div class="w-full max-w-4xl mx-auto">
<div class="mx-auto w-full max-w-4xl">
<Comp_exhibit_payment />
</div>
{:else if active_tab === 'list'}
<div class="w-full flex flex-col space-y-6">
<div class="flex justify-between items-center px-2">
<h2 class="text-xl sm:text-2xl font-bold">Lead List</h2>
<div class="flex w-full flex-col space-y-6">
<div class="flex items-center justify-between px-2">
<h2 class="text-xl font-bold sm:text-2xl">Lead List</h2>
{#if $lq__exhibit_obj?.leads_api_access === true}
<button
type="button"
class="btn btn-sm preset-outlined-secondary"
onclick={handle_export}
>
onclick={handle_export}>
<Download size="1.2em" class="mr-2" /> Export
</button>
{/if}
</div>
<Comp_exhibit_tracking_search exhibit_id={page.params.exhibit_id ?? ''} />
<Comp_exhibit_tracking_search
exhibit_id={page.params.exhibit_id ?? ''} />
{#if $events_sess.leads.submit_status__search === 'searching' && tracking_id_li.length === 0}
<div
class="flex flex-col items-center justify-center p-10 opacity-50 text-center w-full"
>
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
class="flex w-full flex-col items-center justify-center p-10 text-center opacity-50">
<LoaderCircle
size="3em"
class="mx-auto mb-4 animate-spin" />
<p class="text-xl">Searching leads...</p>
</div>
{:else}
<Comp_exhibit_tracking_obj_li lq__event_exhibit_tracking_obj_li={filtered_lead_li} />
<Comp_exhibit_tracking_obj_li
lq__event_exhibit_tracking_obj_li={filtered_lead_li} />
{/if}
</div>
{:else if active_tab === 'manage'}
<div class="w-full max-w-4xl mx-auto">
<div class="mx-auto w-full max-w-4xl">
<Tab_manage />
</div>
{/if}
</div>
</div>
</section>

View File

@@ -1,138 +1,186 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte
* Exhibitor Custom Questions Editor - Handles leads_custom_questions_json.
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { List, LoaderCircle, MessageSquare, Plus, Save, Trash2 } from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
custom_questions_json?: string;
}
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte
* Exhibitor Custom Questions Editor - Handles leads_custom_questions_json.
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import {
List,
LoaderCircle,
MessageSquare,
Plus,
Save,
Trash2
} from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
custom_questions_json?: string;
}
let { exhibit_id, event_id, custom_questions_json = '[]' }: Props = $props();
let { exhibit_id, event_id, custom_questions_json = '[]' }: Props = $props();
let questions: any[] = $state([]);
let is_saving = $state(false);
// Track the JSON as it was last saved so we can detect unsaved changes
let saved_json = $state('[]');
let questions: any[] = $state([]);
let is_saving = $state(false);
// Track the JSON as it was last saved so we can detect unsaved changes
let saved_json = $state('[]');
$effect(() => {
const incoming = custom_questions_json; // reactive dependency
try {
const parsed = JSON.parse(incoming || '[]');
untrack(() => {
if (Array.isArray(parsed) && parsed.length > 0) {
// Incoming prop has real content — load it (initial load or external update)
questions = parsed;
saved_json = JSON.stringify(parsed);
} else if (questions.length === 0) {
// Both empty — initialize state cleanly
saved_json = '[]';
}
// If parsed is empty but we already have questions: the API response
// stripped leads_custom_questions_json from its return object and
// overwrote Dexie with null. Keep our in-memory questions intact.
});
} catch (e) {
untrack(() => { if (questions.length === 0) { questions = []; saved_json = '[]'; } });
}
});
// True whenever the current questions differ from the last saved state
let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
async function save_questions() {
if (!exhibit_id) return;
is_saving = true;
try {
await events_func.update_ae_obj__exhibit({
api_cfg: $ae_api,
event_id: event_id,
exhibit_id: exhibit_id,
data_kv: {
leads_custom_questions_json: JSON.stringify(questions)
}
});
saved_json = JSON.stringify(questions);
} finally {
is_saving = false;
}
}
function add_question() {
questions.push({
// code: machine key used as the responses_json property name
// question: human-readable label shown to the exhibitor/scanner
// option_li: array of choices; first element is always '' (blank/no-selection default)
code: '',
question: '',
type: 'text',
option_li: ['']
$effect(() => {
const incoming = custom_questions_json; // reactive dependency
try {
const parsed = JSON.parse(incoming || '[]');
untrack(() => {
if (Array.isArray(parsed) && parsed.length > 0) {
// Incoming prop has real content — load it (initial load or external update)
questions = parsed;
saved_json = JSON.stringify(parsed);
} else if (questions.length === 0) {
// Both empty — initialize state cleanly
saved_json = '[]';
}
// If parsed is empty but we already have questions: the API response
// stripped leads_custom_questions_json from its return object and
// overwrote Dexie with null. Keep our in-memory questions intact.
});
} catch (e) {
untrack(() => {
if (questions.length === 0) {
questions = [];
saved_json = '[]';
}
});
}
});
function remove_question(index: number) {
questions.splice(index, 1);
}
// True whenever the current questions differ from the last saved state
let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
// Helpers for option_li ↔ comma-string conversion in the UI
function get_options_str(q: any): string {
const li: string[] = Array.isArray(q.option_li) ? q.option_li : [];
return li.filter((o: string) => o !== '').join(', ');
async function save_questions() {
if (!exhibit_id) return;
is_saving = true;
try {
await events_func.update_ae_obj__exhibit({
api_cfg: $ae_api,
event_id: event_id,
exhibit_id: exhibit_id,
data_kv: {
leads_custom_questions_json: JSON.stringify(questions)
}
});
saved_json = JSON.stringify(questions);
} finally {
is_saving = false;
}
}
function set_options_str(q: any, val: string) {
// Always prepend empty string so the select has a blank default option
q.option_li = ['', ...val.split(',').map((s: string) => s.trim()).filter(Boolean)];
}
function add_question() {
questions.push({
// code: machine key used as the responses_json property name
// question: human-readable label shown to the exhibitor/scanner
// option_li: array of choices; first element is always '' (blank/no-selection default)
code: '',
question: '',
type: 'text',
option_li: ['']
});
}
function remove_question(index: number) {
questions.splice(index, 1);
}
// Helpers for option_li ↔ comma-string conversion in the UI
function get_options_str(q: any): string {
const li: string[] = Array.isArray(q.option_li) ? q.option_li : [];
return li.filter((o: string) => o !== '').join(', ');
}
function set_options_str(q: any, val: string) {
// Always prepend empty string so the select has a blank default option
q.option_li = [
'',
...val
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
];
}
</script>
<div class="custom-questions-editor space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Lead Qualifiers</h3>
<span class="text-xs opacity-40 italic">Define questions for lead capture</span>
<h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
Lead Qualifiers
</h3>
<span class="text-xs italic opacity-40"
>Define questions for lead capture</span>
</div>
<div class="space-y-3">
{#each questions as q, i (i)}
<div class="card p-4 preset-tonal-surface border border-surface-500/10 space-y-3 animate-in fade-in slide-in-from-right-2">
<div
class="card preset-tonal-surface border-surface-500/10 animate-in fade-in slide-in-from-right-2 space-y-3 border p-4">
<!-- Question header row: number + delete (always visible for mobile) -->
<div class="flex items-center justify-between">
<span class="text-[10px] uppercase font-black opacity-30 tracking-widest">Question {i + 1}</span>
<span
class="text-[10px] font-black tracking-widest uppercase opacity-30"
>Question {i + 1}</span>
<button
class="btn btn-sm preset-outlined-error px-2 py-1"
onclick={() => remove_question(i)}
title="Remove question"
>
title="Remove question">
<Trash2 size="1em" />
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Question / display label -->
<div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-question">Question / Label</label>
<label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-question">Question / Label</label>
<div class="flex items-center gap-2">
<MessageSquare size="1em" class="opacity-30 flex-none" />
<input id="custom-q-{i}-question" type="text" bind:value={q.question} placeholder="e.g. Purchasing Authority?" class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-sm font-bold" />
<MessageSquare
size="1em"
class="flex-none opacity-30" />
<input
id="custom-q-{i}-question"
type="text"
bind:value={q.question}
placeholder="e.g. Purchasing Authority?"
class="border-surface-500/20 flex-1 border-b bg-transparent text-sm font-bold outline-none" />
</div>
</div>
<!-- Code / machine key -->
<div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-code">Field Code <span class="normal-case font-normal opacity-70">(key in export)</span></label>
<input id="custom-q-{i}-code" type="text" bind:value={q.code} placeholder="e.g. purchasing_authority" class="bg-transparent border-b border-surface-500/20 outline-none w-full text-xs font-mono" />
<label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-code"
>Field Code <span
class="font-normal normal-case opacity-70"
>(key in export)</span
></label>
<input
id="custom-q-{i}-code"
type="text"
bind:value={q.code}
placeholder="e.g. purchasing_authority"
class="border-surface-500/20 w-full border-b bg-transparent font-mono text-xs outline-none" />
</div>
</div>
<!-- Response Type -->
<div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-type">Response Type</label>
<select id="custom-q-{i}-type" bind:value={q.type} class="select preset-tonal-surface text-xs p-1 rounded w-full">
<label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-type">Response Type</label>
<select
id="custom-q-{i}-type"
bind:value={q.type}
class="select preset-tonal-surface w-full rounded p-1 text-xs">
<option value="text">Short Text</option>
<option value="textarea">Long Text</option>
<option value="toggle">Yes / No (Toggle)</option>
@@ -141,18 +189,24 @@
</div>
{#if q.type === 'option'}
<div class="space-y-1 pt-2 border-t border-surface-500/10">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-options">Options (comma-separated)</label>
<div class="border-surface-500/10 space-y-1 border-t pt-2">
<label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-options"
>Options (comma-separated)</label>
<div class="flex items-center gap-2">
<List size="1em" class="opacity-30 flex-none" />
<List size="1em" class="flex-none opacity-30" />
<input
id="custom-q-{i}-options"
type="text"
value={get_options_str(q)}
oninput={(e) => set_options_str(q, (e.target as HTMLInputElement).value)}
oninput={(e) =>
set_options_str(
q,
(e.target as HTMLInputElement).value
)}
placeholder="Hot, Warm, Cold"
class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-xs"
/>
class="border-surface-500/20 flex-1 border-b bg-transparent text-xs outline-none" />
</div>
</div>
{/if}
@@ -160,7 +214,8 @@
{/each}
{#if questions.length === 0}
<div class="p-8 text-center border-2 border-dashed border-surface-500/20 rounded-xl opacity-30">
<div
class="border-surface-500/20 rounded-xl border-2 border-dashed p-8 text-center opacity-30">
<Plus size="2em" class="mx-auto mb-2" />
<p class="text-sm italic">No custom questions defined yet.</p>
</div>
@@ -169,11 +224,15 @@
<!-- Unsaved changes warning -->
{#if is_dirty}
<p class="text-xs text-warning-500 font-bold text-center animate-pulse">Unsaved changes</p>
<p class="text-warning-500 animate-pulse text-center text-xs font-bold">
Unsaved changes
</p>
{/if}
<div class="flex gap-2 pt-2">
<button class="btn btn-sm preset-filled-secondary flex-1" onclick={add_question}>
<button
class="btn btn-sm preset-filled-secondary flex-1"
onclick={add_question}>
<Plus size="1.2em" class="mr-2" /> Add Question
</button>
<button
@@ -181,10 +240,9 @@
class:preset-filled-primary={is_dirty}
class:preset-outlined-surface={!is_dirty}
onclick={save_questions}
disabled={is_saving || !is_dirty}
>
disabled={is_saving || !is_dirty}>
{#if is_saving}
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
<LoaderCircle size="1.2em" class="mr-2 animate-spin" />
{:else}
<Save size="1.2em" class="mr-2" />
{/if}

View File

@@ -1,148 +1,164 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte
* Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { Key, LoaderCircle, Mail, Plus, Save, Trash2, User, Users } from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
license_li_json?: string; // Raw JSON string from DB
license_max?: number;
}
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte
* Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import {
Key,
LoaderCircle,
Mail,
Plus,
Save,
Trash2,
User,
Users
} from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
license_li_json?: string; // Raw JSON string from DB
license_max?: number;
}
let { exhibit_id, event_id, license_li_json = '[]', license_max = 0 }: Props = $props();
let {
exhibit_id,
event_id,
license_li_json = '[]',
license_max = 0
}: Props = $props();
// Local state for the parsed list
let local_license_li: any[] = $state([]);
let is_saving = $state(false);
// Local state for the parsed list
let local_license_li: any[] = $state([]);
let is_saving = $state(false);
// Parse JSON into local state
$effect(() => {
try {
const raw = license_li_json;
if (!raw) {
untrack(() => local_license_li = []);
return;
}
// Handle both string and pre-parsed array/object formats
let parsed = [];
if (Array.isArray(raw)) {
parsed = raw;
} else if (typeof raw === 'string') {
parsed = JSON.parse(raw || '[]');
}
untrack(() => {
local_license_li = Array.isArray(parsed) ? parsed : [];
});
} catch (e) {
console.error('Failed to parse license_li_json', e);
untrack(() => {
local_license_li = [];
});
}
});
async function save_licenses() {
if (!exhibit_id) return;
is_saving = true;
try {
const json_str = JSON.stringify(local_license_li);
await events_func.update_ae_obj__exhibit({
api_cfg: $ae_api,
event_id: event_id,
exhibit_id: exhibit_id,
data_kv: {
license_li_json: json_str
}
});
} catch (e) {
console.error('Failed to save licenses', e);
} finally {
is_saving = false;
}
}
function add_license() {
if (local_license_li.length >= (license_max || 1)) {
alert(`Maximum licenses (${license_max}) reached.`);
// Parse JSON into local state
$effect(() => {
try {
const raw = license_li_json;
if (!raw) {
untrack(() => (local_license_li = []));
return;
}
local_license_li.push({
full_name: '',
email: '',
passcode: Math.random().toString(36).substring(2, 8).toUpperCase()
// Handle both string and pre-parsed array/object formats
let parsed = [];
if (Array.isArray(raw)) {
parsed = raw;
} else if (typeof raw === 'string') {
parsed = JSON.parse(raw || '[]');
}
untrack(() => {
local_license_li = Array.isArray(parsed) ? parsed : [];
});
} catch (e) {
console.error('Failed to parse license_li_json', e);
untrack(() => {
local_license_li = [];
});
}
});
function remove_license(index: number) {
if (confirm('Remove this license? The user will lose access immediately.')) {
local_license_li.splice(index, 1);
}
async function save_licenses() {
if (!exhibit_id) return;
is_saving = true;
try {
const json_str = JSON.stringify(local_license_li);
await events_func.update_ae_obj__exhibit({
api_cfg: $ae_api,
event_id: event_id,
exhibit_id: exhibit_id,
data_kv: {
license_li_json: json_str
}
});
} catch (e) {
console.error('Failed to save licenses', e);
} finally {
is_saving = false;
}
}
function add_license() {
if (local_license_li.length >= (license_max || 1)) {
alert(`Maximum licenses (${license_max}) reached.`);
return;
}
local_license_li.push({
full_name: '',
email: '',
passcode: Math.random().toString(36).substring(2, 8).toUpperCase()
});
}
function remove_license(index: number) {
if (
confirm('Remove this license? The user will lose access immediately.')
) {
local_license_li.splice(index, 1);
}
}
</script>
<div class="exhibit-license-list space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Assigned Licenses</h3>
<span class="text-xs font-mono bg-surface-500/10 px-2 py-1 rounded">
<h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
Assigned Licenses
</h3>
<span class="bg-surface-500/10 rounded px-2 py-1 font-mono text-xs">
{local_license_li.length} / {license_max || 1}
</span>
</div>
<div class="space-y-3">
{#each local_license_li as license, i (i)}
<div class="card p-4 preset-tonal-surface border border-surface-500/10 space-y-3 relative group animate-in fade-in slide-in-from-right-2">
<button
class="absolute top-2 right-2 p-2 text-error-500 opacity-0 group-hover:opacity-100 transition-opacity"
<div
class="card preset-tonal-surface border-surface-500/10 group animate-in fade-in slide-in-from-right-2 relative space-y-3 border p-4">
<button
class="text-error-500 absolute top-2 right-2 p-2 opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => remove_license(i)}
title="Remove License"
>
title="Remove License">
<Trash2 size="1.2em" />
</button>
<!-- Name -->
<div class="flex items-center gap-3">
<User size="1.2em" class="opacity-30" />
<input
type="text"
bind:value={license.full_name}
placeholder="Full Name"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none flex-1 text-sm font-bold"
/>
<input
type="text"
bind:value={license.full_name}
placeholder="Full Name"
class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm font-bold outline-none" />
</div>
<!-- Email -->
<div class="flex items-center gap-3">
<Mail size="1.2em" class="opacity-30" />
<input
type="email"
bind:value={license.email}
placeholder="email@example.com"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none flex-1 text-sm"
/>
<input
type="email"
bind:value={license.email}
placeholder="email@example.com"
class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm outline-none" />
</div>
<!-- Passcode -->
<div class="flex items-center gap-3">
<Key size="1.2em" class="opacity-30" />
<input
type="text"
bind:value={license.passcode}
placeholder="PASSCODE"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none w-32 text-sm font-mono font-bold"
/>
<input
type="text"
bind:value={license.passcode}
placeholder="PASSCODE"
class="border-surface-500/20 focus:border-primary-500 w-32 border-b bg-transparent font-mono text-sm font-bold outline-none" />
</div>
</div>
{/each}
{#if local_license_li.length === 0}
<div class="p-8 text-center border-2 border-dashed border-surface-500/20 rounded-xl opacity-30">
<div
class="border-surface-500/20 rounded-xl border-2 border-dashed p-8 text-center opacity-30">
<Users size="2em" class="mx-auto mb-2" />
<p class="text-sm italic">No licenses assigned yet.</p>
</div>
@@ -150,25 +166,23 @@
</div>
<div class="flex gap-2 pt-2">
<button
class="btn btn-sm preset-filled-secondary flex-1"
<button
class="btn btn-sm preset-filled-secondary flex-1"
onclick={add_license}
disabled={local_license_li.length >= (license_max || 1)}
>
disabled={local_license_li.length >= (license_max || 1)}>
<Plus size="1.2em" class="mr-2" /> Add Leads Licensee
</button>
<button
class="btn btn-sm preset-filled-primary flex-1"
<button
class="btn btn-sm preset-filled-primary flex-1"
onclick={save_licenses}
disabled={is_saving}
>
disabled={is_saving}>
{#if is_saving}
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
<LoaderCircle size="1.2em" class="mr-2 animate-spin" />
{:else}
<Save size="1.2em" class="mr-2" />
{/if}
Save Changes
</button>
</div>
</div>
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
* Leads Payment Stub.
*/
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
* Leads Payment Stub.
*/
</script>
<div class="exhibit-payment p-4 card">
<div class="exhibit-payment card p-4">
<h3 class="h3">Payment & Licensing</h3>
<p>Placeholder for Stripe integration.</p>
</div>

View File

@@ -1,218 +1,255 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
* Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { ArrowRight, CircleAlert, CircleCheck, Key, LoaderCircle, Lock, Mail, User } from '@lucide/svelte';
import { untrack } from 'svelte';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
* Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import {
ArrowRight,
CircleAlert,
CircleCheck,
Key,
LoaderCircle,
Lock,
Mail,
User
} from '@lucide/svelte';
import { untrack } from 'svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
// Form State
let signin_mode = $state('passcode'); // 'passcode' or 'licensed'
let passcode = $state('');
let email = $state('');
let user_passcode = $state('');
let status = $state('idle'); // 'idle', 'submitting', 'error', 'success'
let error_msg = $state('');
// Form State
let signin_mode = $state('passcode'); // 'passcode' or 'licensed'
let passcode = $state('');
let email = $state('');
let user_passcode = $state('');
let status = $state('idle'); // 'idle', 'submitting', 'error', 'success'
let error_msg = $state('');
// --- Auto-prefill for Trusted Users ---
$effect(() => {
if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) {
untrack(() => {
if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? '';
});
// --- Auto-prefill for Trusted Users ---
$effect(() => {
if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) {
untrack(() => {
if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? '';
});
}
});
async function handle_signin() {
if (!$lq__exhibit_obj) return;
status = 'submitting';
error_msg = '';
// Delay for better UX
await new Promise((r) => setTimeout(r, 800));
if (signin_mode === 'passcode') {
// 1. Shared Passcode logic
if (passcode === $lq__exhibit_obj.staff_passcode) {
// SUCCESS
complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
} else {
status = 'error';
error_msg =
'Invalid shared passcode. Please check with your booth manager.';
}
});
} else {
// 2. Licensed User logic
try {
// Determine raw JSON string
const raw_json = $lq__exhibit_obj?.license_li_json;
async function handle_signin() {
if (!$lq__exhibit_obj) return;
status = 'submitting';
error_msg = '';
// Parse if string, otherwise use empty array
const licenses =
typeof raw_json === 'string'
? JSON.parse(raw_json || '[]')
: Array.isArray(raw_json)
? raw_json
: [];
// Delay for better UX
await new Promise(r => setTimeout(r, 800));
const found = licenses.find(
(l: any) =>
l.email?.toLowerCase() === email.toLowerCase().trim()
);
if (signin_mode === 'passcode') {
// 1. Shared Passcode logic
if (passcode === $lq__exhibit_obj.staff_passcode) {
if (found && found.passcode === user_passcode) {
// SUCCESS
complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
complete_signin(found.email, 'licensed');
} else {
status = 'error';
error_msg = 'Invalid shared passcode. Please check with your booth manager.';
}
} else {
// 2. Licensed User logic
try {
// Determine raw JSON string
const raw_json = $lq__exhibit_obj?.license_li_json;
// Parse if string, otherwise use empty array
const licenses = typeof raw_json === 'string' ? JSON.parse(raw_json || '[]') : (Array.isArray(raw_json) ? raw_json : []);
const found = licenses.find((l: any) => l.email?.toLowerCase() === email.toLowerCase().trim());
if (found && found.passcode === user_passcode) {
// SUCCESS
complete_signin(found.email, 'licensed');
} else {
status = 'error';
error_msg = 'Invalid email or personal passcode.';
}
} catch (e) {
status = 'error';
error_msg = 'System error validating licenses.';
error_msg = 'Invalid email or personal passcode.';
}
} catch (e) {
status = 'error';
error_msg = 'System error validating licenses.';
}
}
}
function complete_signin(key: string, type: string) {
status = 'success';
function complete_signin(key: string, type: string) {
status = 'success';
// Save to persistent store
if (!$events_loc.leads.auth_exhibit_kv) $events_loc.leads.auth_exhibit_kv = {};
// Save to persistent store
if (!$events_loc.leads.auth_exhibit_kv)
$events_loc.leads.auth_exhibit_kv = {};
$events_loc.leads.auth_exhibit_kv[exhibit_id] = {
key: key,
type: type,
updated_on: new Date().toISOString()
};
$events_loc.leads.auth_exhibit_kv[exhibit_id] = {
key: key,
type: type,
updated_on: new Date().toISOString()
};
// Also update session passcode if shared mode
if (type === 'shared') {
$events_sess.leads.entered_passcode = key;
}
// Trigger a reload or UI update if needed
// (The parent +page.svelte should reactively update is_signed_in)
// Also update session passcode if shared mode
if (type === 'shared') {
$events_sess.leads.entered_passcode = key;
}
// Trigger a reload or UI update if needed
// (The parent +page.svelte should reactively update is_signed_in)
}
</script>
<div class="exhibit-signin card p-6 preset-tonal-surface shadow-xl border border-surface-500/20 space-y-6">
<div
class="exhibit-signin card preset-tonal-surface border-surface-500/20 space-y-6 border p-6 shadow-xl">
<!-- Tab Toggle -->
<div class="flex p-1 bg-surface-500/10 rounded-xl">
<div class="bg-surface-500/10 flex rounded-xl p-1">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-bold transition-all"
class="flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-bold transition-all"
class:bg-surface-100-900={signin_mode === 'passcode'}
class:shadow-sm={signin_mode === 'passcode'}
class:opacity-50={signin_mode !== 'passcode'}
onclick={() => signin_mode = 'passcode'}
>
onclick={() => (signin_mode = 'passcode')}>
<Lock size="1.2em" /> Shared Code
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-bold transition-all"
class="flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-bold transition-all"
class:bg-surface-100-900={signin_mode === 'licensed'}
class:shadow-sm={signin_mode === 'licensed'}
class:opacity-50={signin_mode !== 'licensed'}
onclick={() => signin_mode = 'licensed'}
>
onclick={() => (signin_mode = 'licensed')}>
<User size="1.2em" /> Licensed User
</button>
</div>
<!-- Forms -->
<form onsubmit={(e) => { e.preventDefault(); handle_signin(); }} class="space-y-4">
<form
onsubmit={(e) => {
e.preventDefault();
handle_signin();
}}
class="space-y-4">
{#if signin_mode === 'passcode'}
<div class="space-y-2 animate-in fade-in slide-in-from-left-2">
<div class="animate-in fade-in slide-in-from-left-2 space-y-2">
<label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Booth Passcode</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
<span
class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
>Booth Passcode</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim"><Key size="1.2em" /></div>
<input
type="text"
bind:value={passcode}
placeholder="Enter shared code..."
class="bg-transparent font-mono tracking-[0.3em] font-bold text-center"
autocomplete="off"
/>
class="bg-transparent text-center font-mono font-bold tracking-[0.3em]"
autocomplete="off" />
</div>
</label>
</div>
{:else}
<div class="space-y-4 animate-in fade-in slide-in-from-right-2">
<div class="animate-in fade-in slide-in-from-right-2 space-y-4">
<label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Email Address</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
<div class="input-group-shim"><Mail size="1.2em" /></div>
<span
class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
>Email Address</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim">
<Mail size="1.2em" />
</div>
<input
type="email"
bind:value={email}
placeholder="your@email.com"
class="bg-transparent"
/>
class="bg-transparent" />
</div>
</label>
<label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Personal Passcode</span>
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
<span
class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
>Personal Passcode</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim"><Key size="1.2em" /></div>
<input
type="text"
bind:value={user_passcode}
placeholder="Your code..."
class="bg-transparent font-mono font-bold"
autocomplete="off"
/>
autocomplete="off" />
</div>
</label>
</div>
{/if}
{#if status === 'error'}
<div class="p-3 rounded-lg preset-tonal-error flex items-start gap-3 animate-shake">
<CircleAlert size="1.2em" class="shrink-0 mt-0.5" />
<p class="text-xs font-bold leading-tight">{error_msg}</p>
<div
class="preset-tonal-error animate-shake flex items-start gap-3 rounded-lg p-3">
<CircleAlert size="1.2em" class="mt-0.5 shrink-0" />
<p class="text-xs leading-tight font-bold">{error_msg}</p>
</div>
{/if}
<button
type="submit"
class="btn btn-lg preset-filled-primary w-full font-bold shadow-lg shadow-primary-500/20 group"
disabled={status === 'submitting'}
>
class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
disabled={status === 'submitting'}>
{#if status === 'submitting'}
<LoaderCircle size="1.5em" class="animate-spin mr-2" />
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
Signing In...
{:else if status === 'success'}
<CircleCheck size="1.5em" class="mr-2" />
Welcome!
{:else}
Get Started <ArrowRight size="1.2em" class="ml-2 group-hover:translate-x-1 transition-transform" />
Get Started <ArrowRight
size="1.2em"
class="ml-2 transition-transform group-hover:translate-x-1" />
{/if}
</button>
</form>
<p class="text-[10px] text-center opacity-40 italic">
<p class="text-center text-[10px] italic opacity-40">
Check your welcome email or ask your booth manager for login details.
</p>
</div>
<style lang="postcss">
/* Shake animation for errors */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
/* Shake animation for errors */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
25% {
transform: translateX(-4px);
}
</style>
75% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}
</style>

View File

@@ -1,74 +1,95 @@
<script lang="ts">
interface Props {
lq__event_exhibit_tracking_obj_li: any;
log_lvl?: number;
interface Props {
lq__event_exhibit_tracking_obj_li: any;
log_lvl?: number;
}
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
import {
ChevronRight,
Clock,
FileText,
LoaderCircle,
Mail,
MapPin,
User
} from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { page } from '$app/state';
// Full ISO datetime for tooltip (hover title)
function format_date_full(date_str: string) {
if (!date_str) return '';
return new Date(date_str).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago"
// Exact for first few minutes, increasingly coarse after that.
function fuzzy_time_ago(date_str: string) {
if (!date_str) return '';
const diff_ms = Date.now() - new Date(date_str).getTime();
if (diff_ms < 0) return 'just now';
const min = diff_ms / 60000;
const hr = diff_ms / 3600000;
const days = Math.floor(hr / 24);
if (min < 0.75) return 'just now';
if (min < 2) return '~1 min ago';
if (min < 7) return `~${Math.round(min)} min ago`; // e.g. "~3 min ago"
if (min < 20) return `~${Math.round(min / 5) * 5} min ago`; // rounds to 5 min
if (min < 55) return `~${Math.round(min / 15) * 15} min ago`; // rounds to 15 min
if (hr < 1.5) return '~1 hr ago';
if (hr < 23.5) {
const r = Math.round(hr * 2) / 2; // rounds to nearest 0.5 hr
return `~${r} hrs ago`;
}
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
import { ChevronRight, Clock, FileText, LoaderCircle, Mail, MapPin, User } from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { page } from '$app/state';
// Full ISO datetime for tooltip (hover title)
function format_date_full(date_str: string) {
if (!date_str) return '';
return new Date(date_str).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
if (days < 7) {
const rem_hr = Math.round(hr - days * 24);
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
return rem_hr > 1
? `${day_label} ${rem_hr} hrs ago`
: `${day_label} ago`;
}
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago"
// Exact for first few minutes, increasingly coarse after that.
function fuzzy_time_ago(date_str: string) {
if (!date_str) return '';
const diff_ms = Date.now() - new Date(date_str).getTime();
if (diff_ms < 0) return 'just now';
const min = diff_ms / 60000;
const hr = diff_ms / 3600000;
const days = Math.floor(hr / 24);
if (min < 0.75) return 'just now';
if (min < 2) return '~1 min ago';
if (min < 7) return `~${Math.round(min)} min ago`; // e.g. "~3 min ago"
if (min < 20) return `~${Math.round(min / 5) * 5} min ago`; // rounds to 5 min
if (min < 55) return `~${Math.round(min / 15) * 15} min ago`; // rounds to 15 min
if (hr < 1.5) return '~1 hr ago';
if (hr < 23.5) {
const r = Math.round(hr * 2) / 2; // rounds to nearest 0.5 hr
return `~${r} hrs ago`;
}
if (days < 7) {
const rem_hr = Math.round(hr - days * 24);
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
return rem_hr > 1 ? `${day_label} ${rem_hr} hrs ago` : `${day_label} ago`;
}
const weeks = Math.round(days / 7);
if (days < 28) { return `~${weeks} week${weeks > 1 ? 's' : ''} ago`; }
const months = Math.round(days / 30);
if (days < 365) { return `~${months} month${months > 1 ? 's' : ''} ago`; }
const years = Math.round(days / 365);
return `~${years} year${years > 1 ? 's' : ''} ago`;
const weeks = Math.round(days / 7);
if (days < 28) {
return `~${weeks} week${weeks > 1 ? 's' : ''} ago`;
}
const months = Math.round(days / 30);
if (days < 365) {
return `~${months} month${months > 1 ? 's' : ''} ago`;
}
const years = Math.round(days / 365);
return `~${years} year${years > 1 ? 's' : ''} ago`;
}
</script>
<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">
<LoaderCircle 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">
<div class="card preset-tonal-surface p-8 text-center">
<p class="text-xl opacity-50">No leads found yet.</p>
<p class="text-sm opacity-50 mt-2">
<p class="mt-2 text-sm opacity-50">
Start scanning badges to collect leads!
</p>
</div>
{:else}
<div class="space-y-4">
<div class="flex justify-between items-center px-2">
<div class="flex items-center justify-between px-2">
<span class="text-sm font-semibold opacity-50">
{lq__event_exhibit_tracking_obj_li.length} Leads Collected
</span>
@@ -78,8 +99,7 @@
{#each lq__event_exhibit_tracking_obj_li as event_tracking_obj (event_tracking_obj.event_exhibit_tracking_id)}
<a
href={`/events/${page.params.event_id}/leads/exhibit/${event_tracking_obj.event_exhibit_id}/lead/${event_tracking_obj.event_exhibit_tracking_id}`}
class="card card-hover p-4 preset-tonal-surface border-l-4 border-primary-500 flex flex-col md:flex-row gap-4 items-start md:items-center"
>
class="card card-hover preset-tonal-surface border-primary-500 flex flex-col items-start gap-4 border-l-4 p-4 md:flex-row md:items-center">
<div class="flex-grow space-y-1">
<div class="flex items-center gap-2">
<User size="1.25em" class="text-primary-500" />
@@ -91,8 +111,7 @@
</div>
<div
class="flex flex-wrap gap-x-4 gap-y-1 text-sm opacity-70"
>
class="flex flex-wrap gap-x-4 gap-y-1 text-sm opacity-70">
{#if event_tracking_obj.event_badge_email}
<div class="flex items-center gap-1">
<Mail size="1em" />
@@ -107,27 +126,31 @@
{/if}
<div
class="flex items-center gap-1"
title={format_date_full(event_tracking_obj.created_on)}
>
title={format_date_full(
event_tracking_obj.created_on
)}>
<Clock size="1em" />
{fuzzy_time_ago(event_tracking_obj.created_on)}
{fuzzy_time_ago(
event_tracking_obj.created_on
)}
</div>
</div>
{#if event_tracking_obj.exhibitor_notes}
<div
class="mt-2 p-2 bg-surface-100-900 rounded text-sm italic border-l-2 border-surface-300-700"
>
<FileText size="1em" class="inline mr-1" />
class="bg-surface-100-900 border-surface-300-700 mt-2 rounded border-l-2 p-2 text-sm italic">
<FileText size="1em" class="mr-1 inline" />
{ae_util.shorten_string({
string: ae_util.strip_html(event_tracking_obj.exhibitor_notes),
string: ae_util.strip_html(
event_tracking_obj.exhibitor_notes
),
max_length: 100
})}
</div>
{/if}
</div>
<div class="flex-shrink-0 self-center hidden md:block">
<div class="hidden flex-shrink-0 self-center md:block">
<ChevronRight size="2em" class="opacity-20" />
</div>
</a>

View File

@@ -1,96 +1,106 @@
<script lang="ts">
interface Props {
exhibit_id: string;
log_lvl?: number;
}
interface Props {
exhibit_id: string;
log_lvl?: number;
}
let { exhibit_id, log_lvl = 0 }: Props = $props();
let { exhibit_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries
import { Eye, EyeOff, Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte';
import { untrack } from 'svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { onMount } from 'svelte';
// *** Import other supporting libraries
import {
Eye,
EyeOff,
Library,
LoaderCircle,
RemoveFormatting,
Search
} from '@lucide/svelte';
import { untrack } from 'svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { onMount } from 'svelte';
let exhibit_obj: any = $state(null);
let exhibit_obj: any = $state(null);
onMount(() => {
const observable = liveQuery(async () => {
if (!exhibit_id) return null;
// 1. Try primary lookup
let res = await db_events.exhibit.get(exhibit_id);
// 2. Fallback to random ID index
if (!res) {
res = await db_events.exhibit.where('event_exhibit_id_random').equals(exhibit_id).first();
}
return res;
});
const subscription = observable.subscribe((value) => {
exhibit_obj = value;
});
return () => subscription.unsubscribe();
onMount(() => {
const observable = liveQuery(async () => {
if (!exhibit_id) return null;
// 1. Try primary lookup
let res = await db_events.exhibit.get(exhibit_id);
// 2. Fallback to random ID index
if (!res) {
res = await db_events.exhibit
.where('event_exhibit_id_random')
.equals(exhibit_id)
.first();
}
return res;
});
const subscription = observable.subscribe((value) => {
exhibit_obj = value;
});
return () => subscription.unsubscribe();
});
// Reactive list derived from the exhibit state (Licensed Exhibit Leads Users)
let licensee_li = $derived.by(() => {
try {
const raw = exhibit_obj?.license_li_json;
if (!raw) return [];
// If it's already an array, return it. If it's a string, parse it.
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') return JSON.parse(raw || '[]');
return [];
} catch (e) {
console.error('Failed to parse licensee_li_json', e);
return [];
// Reactive list derived from the exhibit state (Licensed Exhibit Leads Users)
let licensee_li = $derived.by(() => {
try {
const raw = exhibit_obj?.license_li_json;
if (!raw) return [];
// If it's already an array, return it. If it's a string, parse it.
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') return JSON.parse(raw || '[]');
return [];
} catch (e) {
console.error('Failed to parse licensee_li_json', e);
return [];
}
});
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
$effect(() => {
// Wait for object to load and check if initialized
if (!exhibit_obj) return;
untrack(() => {
if (
$events_loc.leads.tracking__qry__licensee_email === 'all' &&
!$ae_loc.administrator_access
) {
$events_loc.leads.tracking__qry__licensee_email = 'my';
}
});
});
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
$effect(() => {
// Wait for object to load and check if initialized
if (!exhibit_obj) return;
untrack(() => {
if ($events_loc.leads.tracking__qry__licensee_email === 'all' && !$ae_loc.administrator_access) {
$events_loc.leads.tracking__qry__licensee_email = 'my';
}
});
});
function handle_search_trigger() {
if ($events_loc.leads.tracking__search_version === undefined) {
$events_loc.leads.tracking__search_version = 0;
}
$events_loc.leads.tracking__search_version++;
function handle_search_trigger() {
if ($events_loc.leads.tracking__search_version === undefined) {
$events_loc.leads.tracking__search_version = 0;
}
$events_loc.leads.tracking__search_version++;
}
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
</script>
<div
class="ae_group filters_and_search flex flex-col items-center justify-center gap-2 w-full"
>
class="ae_group filters_and_search flex w-full flex-col items-center justify-center gap-2">
<form
onsubmit={prevent_default(() => {
handle_search_trigger();
})}
autocomplete="off"
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full px-2 py-2 preset-tonal-primary rounded-lg shadow-sm"
>
class="search_form preset-tonal-primary flex w-full flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm">
<div
class="flex flex-col md:flex-row items-center justify-center gap-1 grow"
>
class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
<input
type="search"
placeholder="Search leads (name, email, notes)..."
@@ -98,20 +108,18 @@
bind:value={$events_loc.leads.tracking__qry__search_text}
autocomplete="off"
data-lpignore="true"
class="input text-lg font-mono grow transition-all"
class="input grow font-mono text-lg transition-all"
onkeyup={(e) => {
if (e.key === 'Enter') {
handle_search_trigger();
}
}}
title="Search by name, email or notes. Press Enter."
/>
title="Search by name, email or notes. Press Enter." />
<select
bind:value={$events_loc.leads.tracking__qry__sort_order}
onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit"
>
class="select select-sm max-w-fit px-1 text-xs">
<option value="created_desc">Newest First</option>
<option value="created_asc">Oldest First</option>
<option value="name_asc">Name ASC</option>
@@ -121,8 +129,7 @@
<select
bind:value={$events_loc.leads.tracking__qry__licensee_email}
onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit"
>
class="select select-sm max-w-fit px-1 text-xs">
<option value="all">All Leads</option>
{#if !$ae_loc.administrator_access}
<option value="my">My Leads</option>
@@ -136,10 +143,9 @@
<div class="flex flex-row items-center justify-center gap-1">
<button
type="submit"
class="btn btn-lg preset-tonal-primary border border-primary-500 hover:preset-tonal-primary text-2xl font-bold w-48 transition-all"
>
class="btn btn-lg preset-tonal-primary border-primary-500 hover:preset-tonal-primary w-48 border text-2xl font-bold transition-all">
{#if $events_sess.leads.submit_status__search === 'searching'}
<LoaderCircle class="animate-spin mx-1" />
<LoaderCircle class="mx-1 animate-spin" />
{:else}
<Search class="mx-1" />
{/if}
@@ -153,9 +159,8 @@
$events_loc.leads.tracking__qry__search_text = '';
handle_search_trigger();
}}
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all"
title="Clear search query"
>
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
title="Clear search query">
<RemoveFormatting size="1.25em" />
<span class="hidden md:inline"> Clear </span>
</button>
@@ -163,20 +168,20 @@
</form>
<div
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 hover:opacity-100 transition-all"
>
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 transition-all hover:opacity-100">
<!-- Show/Hide hidden records toggle — always visible -->
<button
type="button"
class="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-token text-xs font-semibold transition-colors"
class="rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold transition-colors"
class:preset-tonal-warning={$events_loc.leads.show_hidden}
class:preset-tonal-surface={!$events_loc.leads.show_hidden}
onclick={() => {
$events_loc.leads.show_hidden = !$events_loc.leads.show_hidden;
handle_search_trigger();
}}
title={$events_loc.leads.show_hidden ? 'Showing hidden leads — click to hide them' : 'Hidden leads excluded — click to show all'}
>
title={$events_loc.leads.show_hidden
? 'Showing hidden leads — click to hide them'
: 'Hidden leads excluded — click to show all'}>
{#if $events_loc.leads.show_hidden}
<Eye size="1em" />
<span>Showing Hidden</span>
@@ -188,15 +193,13 @@
{#if $ae_loc.edit_mode}
<label
class="flex items-center gap-1 cursor-pointer bg-surface-200-800 px-2 py-1 rounded-token text-xs font-semibold"
>
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
<span> Remote First </span>
<input
type="checkbox"
bind:checked={$events_loc.leads.tracking__qry__remote_first}
onchange={handle_search_trigger}
class="checkbox checkbox-sm"
/>
class="checkbox checkbox-sm" />
</label>
{/if}
</div>

View File

@@ -1,146 +1,167 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte
* Manual Attendee Search for adding leads.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { ae_util } from '$lib/ae_utils/ae_utils';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte
* Manual Attendee Search for adding leads.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { ae_util } from '$lib/ae_utils/ae_utils';
interface Props {
exhibit_id: string;
on_lead_added?: (badge: ae_EventBadge) => void;
interface Props {
exhibit_id: string;
on_lead_added?: (badge: ae_EventBadge) => void;
}
let { exhibit_id, on_lead_added }: Props = $props();
// Track existing leads to prevent duplicates in UI
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
// Map badge_id -> tracking_id
const map = new Map();
leads.forEach((l) => {
const b_id =
l.event_badge_id_random || l.event_badge_id?.toString();
if (b_id)
map.set(
b_id,
l.event_exhibit_tracking_id_random ||
l.event_exhibit_tracking_id?.toString()
);
});
return map;
})
);
let search_query = $state('');
let results: ae_EventBadge[] = $state([]);
let searching = $state(false);
let adding_id = $state(''); // badge_id currently being added (shows spinner)
let add_error_id = $state(''); // badge_id that failed to add (shows error)
// Track the most recently added badge_id → tracking_id so we can show a View link
let last_added_badge_id = $state('');
let last_added_tracking_id = $state('');
async function handle_search() {
if (!search_query.trim()) return;
searching = true;
add_error_id = '';
try {
const search_results = await events_func.search__event_badge({
api_cfg: $ae_api,
event_id: page.params.event_id || '',
fulltext_search_qry_str: search_query,
limit: 20
});
results = Array.isArray(search_results) ? search_results : [];
} catch (e) {
console.error('Badge search failed', e);
} finally {
searching = false;
}
}
async function add_as_lead(badge: ae_EventBadge) {
// Use id or id_random — whichever is populated from search results
// NO LONGER USE "_random"
const badge_id = badge.event_badge_id_random || badge.event_badge_id;
if (!badge_id) {
console.warn(
'[add_as_lead] badge missing event_badge_id_random and event_badge_id',
badge
);
return;
}
let { exhibit_id, on_lead_added }: Props = $props();
// Track existing leads to prevent duplicates in UI
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
// Map badge_id -> tracking_id
const map = new Map();
leads.forEach(l => {
const b_id = l.event_badge_id_random || l.event_badge_id?.toString();
if (b_id) map.set(b_id, l.event_exhibit_tracking_id_random || l.event_exhibit_tracking_id?.toString());
});
return map;
})
);
let search_query = $state('');
let results: ae_EventBadge[] = $state([]);
let searching = $state(false);
let adding_id = $state(''); // badge_id currently being added (shows spinner)
let add_error_id = $state(''); // badge_id that failed to add (shows error)
// Track the most recently added badge_id → tracking_id so we can show a View link
let last_added_badge_id = $state('');
let last_added_tracking_id = $state('');
async function handle_search() {
if (!search_query.trim()) return;
searching = true;
add_error_id = '';
try {
const search_results = await events_func.search__event_badge({
api_cfg: $ae_api,
event_id: page.params.event_id || '',
fulltext_search_qry_str: search_query,
limit: 20
});
results = Array.isArray(search_results) ? search_results : [];
} catch (e) {
console.error('Badge search failed', e);
} finally {
searching = false;
}
// 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;
}
async function add_as_lead(badge: ae_EventBadge) {
// Use id or id_random — whichever is populated from search results
// NO LONGER USE "_random"
const badge_id = badge.event_badge_id_random || badge.event_badge_id;
if (!badge_id) {
console.warn('[add_as_lead] badge missing event_badge_id_random and event_badge_id', badge);
return;
}
adding_id = badge_id;
add_error_id = '';
// 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;
}
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email =
kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
adding_id = badge_id;
add_error_id = '';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: badge_id,
external_person_id: user_email,
group: user_email
});
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
// Surface a View Details link next to this result row
last_added_badge_id = badge_id;
last_added_tracking_id = result.event_exhibit_tracking_id_random || String(result.event_exhibit_tracking_id || '');
if (on_lead_added) on_lead_added(badge);
} else {
// API returned null/false — surface a visible error on this row
add_error_id = badge_id;
console.warn('[add_as_lead] API returned null for badge_id', badge_id);
}
} catch (e) {
if (result) {
// Surface a View Details link next to this result row
last_added_badge_id = badge_id;
last_added_tracking_id =
result.event_exhibit_tracking_id_random ||
String(result.event_exhibit_tracking_id || '');
if (on_lead_added) on_lead_added(badge);
} else {
// API returned null/false — surface a visible error on this row
add_error_id = badge_id;
console.error('[add_as_lead] Failed to add lead', e);
} finally {
adding_id = '';
console.warn(
'[add_as_lead] API returned null for badge_id',
badge_id
);
}
} catch (e) {
add_error_id = badge_id;
console.error('[add_as_lead] Failed to add lead', e);
} finally {
adding_id = '';
}
}
</script>
<div class="lead-manual-search space-y-4 w-full">
<div class="lead-manual-search w-full space-y-4">
<form
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full px-2 py-2 preset-tonal-primary rounded-lg shadow-sm"
onsubmit={(e) => { e.preventDefault(); handle_search(); }}
>
<div class="flex flex-col md:flex-row items-center justify-center gap-1 grow">
class="search_form preset-tonal-primary flex w-full flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm"
onsubmit={(e) => {
e.preventDefault();
handle_search();
}}>
<div
class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
<input
type="search"
bind:value={search_query}
placeholder="Attendee name, email, or badge ID..."
class="input text-lg font-mono grow transition-all w-full"
/>
class="input w-full grow font-mono text-lg transition-all" />
</div>
<div class="flex flex-row items-center justify-center gap-1">
<button
type="submit"
class="btn btn-lg preset-tonal-primary border border-primary-500 hover:preset-tonal-primary text-2xl font-bold w-48 transition-all"
disabled={searching}
>
class="btn btn-lg preset-tonal-primary border-primary-500 hover:preset-tonal-primary w-48 border text-2xl font-bold transition-all"
disabled={searching}>
{#if searching}
<LoaderCircle class="animate-spin mx-1" size="1.2em" />
<LoaderCircle class="mx-1 animate-spin" size="1.2em" />
{:else}
<Search class="mx-1" size="1.2em" />
{/if}
@@ -150,33 +171,46 @@
</form>
{#if results.length > 0}
<div class="results-list space-y-2 max-h-[50vh] overflow-y-auto pr-2">
<div class="results-list max-h-[50vh] space-y-2 overflow-y-auto pr-2">
{#each results as badge (badge.event_badge_id_random ?? badge.event_badge_id)}
{@const badge_id = badge.event_badge_id_random || badge.event_badge_id}
{@const existing_id = $existing_leads_map?.get(badge_id) ?? (last_added_badge_id === badge_id ? last_added_tracking_id : '')}
<div class="card p-3 flex justify-between items-center preset-tonal-surface shadow-sm">
{@const badge_id =
badge.event_badge_id_random || badge.event_badge_id}
{@const existing_id =
$existing_leads_map?.get(badge_id) ??
(last_added_badge_id === badge_id
? last_added_tracking_id
: '')}
<div
class="card preset-tonal-surface flex items-center justify-between p-3 shadow-sm">
<div>
<div class="font-bold">{badge.full_name}</div>
<div class="text-xs opacity-70">{badge.affiliations || badge.email || ''}</div>
<div class="text-xs opacity-70">
{badge.affiliations || badge.email || ''}
</div>
</div>
{#if existing_id}
<a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_id}`}
class="btn btn-sm preset-filled-secondary"
>
class="btn btn-sm preset-filled-secondary">
<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.">
<span
class="text-warning-500 flex items-center gap-1 text-xs 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)}>
<span class="text-error-500 text-xs font-bold"
>Add failed — retry?
<button
type="button"
class="btn btn-sm preset-outlined-error ml-1"
onclick={() => add_as_lead(badge)}>
Retry
</button>
</span>
@@ -185,8 +219,7 @@
type="button"
class="btn btn-sm preset-filled-success"
disabled={!!adding_id && adding_id === badge_id}
onclick={() => add_as_lead(badge)}
>
onclick={() => add_as_lead(badge)}>
{#if adding_id === badge_id}
<LoaderCircle class="animate-spin" size="1em" />
{:else}
@@ -199,6 +232,8 @@
{/each}
</div>
{:else if !searching && search_query}
<p class="text-center opacity-50 py-4 italic">No attendees found matching "{search_query}"</p>
<p class="py-4 text-center italic opacity-50">
No attendees found matching "{search_query}"
</p>
{/if}
</div>
</div>

View File

@@ -1,275 +1,319 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
* Badge QR Scanner for adding leads.
*
* scan_qualify modes (controlled by parent ae_tab__add):
* - 'rapid': after add → auto-reset scanner (scan next person fast)
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers)
*/
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, RotateCcw, ShieldOff, X } from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
* Badge QR Scanner for adding leads.
*
* scan_qualify modes (controlled by parent ae_tab__add):
* - 'rapid': after add → auto-reset scanner (scan next person fast)
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers)
*/
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
Camera,
CircleAlert,
CircleCheck,
Eye,
LoaderCircle,
RefreshCw,
RotateCcw,
ShieldOff,
X
} from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types';
interface Props {
exhibit_id: string;
scan_qualify?: 'rapid' | 'qualify' | 'auto';
on_lead_added?: (badge: ae_EventBadge) => void;
}
interface Props {
exhibit_id: string;
scan_qualify?: 'rapid' | 'qualify' | 'auto';
on_lead_added?: (badge: ae_EventBadge) => void;
}
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
// Track existing leads to detect duplicates and previously-removed records.
// Value includes tracking_id AND enabled status so we can offer re-activation.
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
// Track existing leads to detect duplicates and previously-removed records.
// Value includes tracking_id AND enabled status so we can offer re-activation.
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>();
leads.forEach(l => {
const b_id = l.event_badge_id?.toString();
if (b_id) map.set(b_id, {
const map = new SvelteMap<
string,
{ tracking_id: string; enabled: boolean }
>();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString();
if (b_id)
map.set(b_id, {
tracking_id: l.event_exhibit_tracking_id?.toString() ?? '',
// enable stored as 1/0 or true/false — !! normalises all falsy values
enabled: !!l.enable
});
});
return map;
})
);
let start_qr_scanner = $state(true);
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable
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
let error_msg = $state('');
async function handle_qr_scan_result(event: {
detail: { result: string; entry_method: string };
}) {
const qr_result = event.detail.result;
const obj = ae_util.process_data_string(qr_result);
if (obj && obj.type === 'event_badge' && obj.id) {
start_qr_scanner = false;
// Check if already exists (enabled or disabled/removed)
if ($existing_leads_map?.has(obj.id)) {
const existing = $existing_leads_map.get(obj.id)!;
existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
scanning_status = existing.enabled ? 'already_added' : 'reenable';
} else {
scanning_status = 'found';
}
// 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
});
return map;
})
);
let start_qr_scanner = $state(true);
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable
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
let error_msg = $state('');
async function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) {
const qr_result = event.detail.result;
const obj = ae_util.process_data_string(qr_result);
if (obj && obj.type === 'event_badge' && obj.id) {
start_qr_scanner = false;
// Check if already exists (enabled or disabled/removed)
if ($existing_leads_map?.has(obj.id)) {
const existing = $existing_leads_map.get(obj.id)!;
existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
scanning_status = existing.enabled ? 'already_added' : 'reenable';
} else {
scanning_status = 'found';
// 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';
}
// Load full badge info (needed for allow_tracking check and display)
// Auto mode: skip the confirm card — add immediately if tracking is allowed.
if (scanning_status === 'found' && scan_qualify === 'auto') {
await confirm_add_lead();
}
} catch (e) {
console.error('Failed to load badge info', e);
}
} else {
scanning_status = 'error';
error_msg = 'Invalid QR code. Please scan an Event Badge.';
}
}
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) {
console.warn(
'[leads] Guard failed — event_badge_id missing. found_badge:',
found_badge
);
return;
}
scanning_status = 'adding';
// Resolve who is capturing this lead:
// licensed exhibit user → their email (kv.key)
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email =
kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: found_badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
// Capture the new tracking ID so we can link to it
new_tracking_id = String(result.event_exhibit_tracking_id || '');
scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
// View Lead: navigate directly to lead detail to fill in notes/qualifiers
goto(
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
);
} else {
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000);
}
} else {
// API returned false/null — could be a duplicate of a previously-removed record
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
// Search the API for a disabled tracking record for this badge before surfacing an error.
try {
found_badge = await events_func.load_ae_obj_id__event_badge({
const disabled_li = await events_func.search__exhibit_tracking({
api_cfg: $ae_api,
event_badge_id: obj.id,
event_id: page.params.event_id,
event_exhibit_id: exhibit_id,
qry_badge_id: found_badge.event_badge_id,
enabled: 'not_enabled',
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';
if (disabled_li.length > 0) {
// Found a disabled record — offer to re-activate instead of showing an error
existing_tracking_id = String(
disabled_li[0].event_exhibit_tracking_id || ''
);
scanning_status = 'reenable';
} else {
scanning_status = 'error';
error_msg =
'Failed to add lead. Check your connection and try again.';
}
} catch {
scanning_status = 'error';
error_msg =
'Failed to add lead. Check your connection and try again.';
}
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. They might already be added.';
}
}
// Auto mode: skip the confirm card — add immediately if tracking is allowed.
if (scanning_status === 'found' && scan_qualify === 'auto') {
await confirm_add_lead();
}
} catch (e) {
console.error('Failed to load badge info', e);
async function confirm_reenable_lead(
dest: 'scan_next' | 'view_lead' = 'scan_next'
) {
// Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return;
scanning_status = 'adding';
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: existing_tracking_id,
data: { enable: true }
});
if (result) {
new_tracking_id = existing_tracking_id;
scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
goto(
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
);
} else {
setTimeout(reset_scanner, 2000);
}
} else {
scanning_status = 'error';
error_msg = 'Invalid QR code. Please scan an Event Badge.';
}
}
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) {
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge);
return;
}
scanning_status = 'adding';
// Resolve who is capturing this lead:
// licensed exhibit user → their email (kv.key)
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: found_badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
// Capture the new tracking ID so we can link to it
new_tracking_id = String(result.event_exhibit_tracking_id || '');
scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
// View Lead: navigate directly to lead detail to fill in notes/qualifiers
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else {
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000);
}
} else {
// API returned false/null — could be a duplicate of a previously-removed record
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
// Search the API for a disabled tracking record for this badge before surfacing an error.
try {
const disabled_li = await events_func.search__exhibit_tracking({
api_cfg: $ae_api,
event_id: page.params.event_id,
event_exhibit_id: exhibit_id,
qry_badge_id: found_badge.event_badge_id,
enabled: 'not_enabled',
log_lvl: 1
});
if (disabled_li.length > 0) {
// Found a disabled record — offer to re-activate instead of showing an error
existing_tracking_id = String(disabled_li[0].event_exhibit_tracking_id || '');
scanning_status = 'reenable';
} else {
scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.';
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.';
}
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. They might already be added.';
}
}
async function confirm_reenable_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
// Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return;
scanning_status = 'adding';
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: existing_tracking_id,
data: { enable: true }
});
if (result) {
new_tracking_id = existing_tracking_id;
scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else {
setTimeout(reset_scanner, 2000);
}
} else {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
}
}
function reset_scanner() {
scanning_status = 'idle';
found_badge = null;
new_tracking_id = '';
error_msg = '';
start_qr_scanner = true;
}
function reset_scanner() {
scanning_status = 'idle';
found_badge = null;
new_tracking_id = '';
error_msg = '';
start_qr_scanner = true;
}
</script>
<div class="lead-qr-scanner flex flex-col items-center space-y-4 w-full min-h-100 justify-center">
<div
class="lead-qr-scanner flex min-h-100 w-full flex-col items-center justify-center space-y-4">
{#if scanning_status === 'idle' || scanning_status === 'scanning'}
<div class="w-full max-w-sm mx-auto aspect-square overflow-hidden rounded-xl border-4 border-surface-500/20 shadow-xl relative bg-surface-900/10">
<div
class="border-surface-500/20 bg-surface-900/10 relative mx-auto aspect-square w-full max-w-sm overflow-hidden rounded-xl border-4 shadow-xl">
<Element_qr_scanner
bind:start_qr_scanner
on_qr_scan_result={handle_qr_scan_result}
/>
<div class="absolute inset-0 pointer-events-none border-2 border-primary-500/50 m-8 sm:m-12 rounded-lg animate-pulse"></div>
on_qr_scan_result={handle_qr_scan_result} />
<div
class="border-primary-500/50 pointer-events-none absolute inset-0 m-8 animate-pulse rounded-lg border-2 sm:m-12">
</div>
</div>
<p class="text-center opacity-70 italic text-sm">Point camera at the badge QR code</p>
<p class="text-center text-sm italic opacity-70">
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" />
<div
class="card preset-tonal-warning border-warning-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<div class="space-y-2 text-center">
<ShieldOff size="3em" class="text-warning-500 mx-auto" />
<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">
<p class="text-xl font-bold">
{found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This attendee has opted out of exhibitor lead scanning.
</p>
</div>
<button
type="button"
class="btn w-full preset-filled-warning font-bold"
onclick={reset_scanner}
>
class="btn preset-filled-warning w-full font-bold"
onclick={reset_scanner}>
<Camera size="1.2em" />
Scan Next
</button>
</div>
{:else if scanning_status === 'reenable'}
<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">
<RotateCcw size="3em" class="mx-auto text-warning-500" />
<div
class="card preset-tonal-warning border-warning-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<div class="space-y-2 text-center">
<RotateCcw size="3em" class="text-warning-500 mx-auto" />
<h3 class="h3 font-bold">Previously Removed</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
<p class="opacity-70 text-sm">This lead was removed. Re-activate to restore their record including any saved notes and responses.</p>
<p class="text-xl font-bold">
{found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This lead was removed. Re-activate to restore their record
including any saved notes and responses.
</p>
</div>
<!-- Two-button confirm — same pattern as the main confirm card -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-warning-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_reenable_lead('scan_next')}
>
class="bg-warning-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_reenable_lead('scan_next')}>
<Camera size="1.5em" />
Restore &amp; Scan Next
</button>
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_reenable_lead('view_lead')}
>
class="bg-secondary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_reenable_lead('view_lead')}>
<Eye size="1.5em" />
Restore &amp; View Lead
</button>
@@ -277,27 +321,29 @@
<button
type="button"
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70"
onclick={reset_scanner}
>
class="border-surface-500/40 hover:bg-surface-200-800 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border py-3 text-sm font-medium opacity-70 transition-colors"
onclick={reset_scanner}>
<X size="1em" />
Cancel / Scan Again
</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">
<CircleCheck size="3em" class="mx-auto text-secondary-500" />
<div
class="card preset-tonal-secondary border-secondary-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<div class="space-y-2 text-center">
<CircleCheck size="3em" class="text-secondary-500 mx-auto" />
<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>
<p class="text-xl font-bold">
{found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This attendee is already in your leads list.
</p>
</div>
<a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_tracking_id}`}
class="btn btn-xl w-full preset-filled-secondary font-bold py-6"
>
class="btn btn-xl preset-filled-secondary w-full py-6 font-bold">
<Eye size="1.5em" class="mr-2" />
View Lead Details
</a>
@@ -305,46 +351,49 @@
<button
type="button"
class="btn btn-sm w-full opacity-50"
onclick={reset_scanner}
>
onclick={reset_scanner}>
<Camera size="1em" />
Scan Next
</button>
</div>
{:else if scanning_status === 'found' || scanning_status === 'adding'}
<!-- bg-surface-50-900: canonical card face token — near-white (light) / deep slate (dark).
Explicit rather than preset-tonal-* so primary/surface buttons have guaranteed contrast. -->
<!-- Buttons use direct Tailwind tokens, not btn/preset-*, because the Skeleton
preset-filled chain resolves to transparent in this card context. -->
<div class="card p-6 w-full max-w-md space-y-4 bg-surface-50-900 shadow-xl border-2 border-primary-500">
<div
class="card bg-surface-50-900 border-primary-500 w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<div class="text-center">
<h3 class="h3 font-bold">{found_badge?.full_name || 'Badge Found'}</h3>
<h3 class="h3 font-bold">
{found_badge?.full_name || 'Badge Found'}
</h3>
<p class="opacity-70">{found_badge?.affiliations || ''}</p>
</div>
{#if scan_qualify === 'auto' || scanning_status === 'adding'}
<!-- Auto mode or mid-add: no buttons — adding happens automatically / in progress -->
<div class="flex items-center justify-center gap-3 py-3 opacity-70">
<div
class="flex items-center justify-center gap-3 py-3 opacity-70">
<LoaderCircle class="animate-spin" size="1.5em" />
<span class="font-bold">{scan_qualify === 'auto' ? 'Auto-adding...' : 'Adding Lead...'}</span>
<span class="font-bold"
>{scan_qualify === 'auto'
? 'Auto-adding...'
: 'Adding Lead...'}</span>
</div>
{:else}
<!-- Two-button confirm: staff chooses what to do after adding this lead -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_add_lead('scan_next')}
>
class="bg-primary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_add_lead('scan_next')}>
<Camera size="1.5em" />
Add &amp; Scan Next
</button>
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_add_lead('view_lead')}
>
class="bg-secondary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_add_lead('view_lead')}>
<Eye size="1.5em" />
Add &amp; View Lead
</button>
@@ -352,19 +401,20 @@
<button
type="button"
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70"
onclick={reset_scanner}
>
class="border-surface-500/40 hover:bg-surface-200-800 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border py-3 text-sm font-medium opacity-70 transition-colors"
onclick={reset_scanner}>
<X size="1em" />
Cancel / Scan Again
</button>
{/if}
</div>
{:else if scanning_status === 'success'}
<div class="card w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-success shadow-xl overflow-hidden">
<div class="p-10 w-full flex flex-col items-center space-y-4">
<CircleCheck size="4em" class="text-success-500 animate-bounce" />
<div
class="card preset-tonal-success flex w-full max-w-md flex-col items-center space-y-4 overflow-hidden shadow-xl">
<div class="flex w-full flex-col items-center space-y-4 p-10">
<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>
@@ -373,26 +423,31 @@
{#if new_tracking_id}
<a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`}
class="btn btn-sm preset-outlined-surface w-full"
>
class="btn btn-sm preset-outlined-surface w-full">
<Eye size="1em" />
View Details
</a>
{/if}
<p class="text-xs opacity-50 uppercase tracking-widest">Scanning next in 2 seconds...</p>
<p class="text-xs tracking-widest uppercase opacity-50">
Scanning next in 2 seconds...
</p>
</div>
<!-- Countdown bar: pure CSS animation depletes over 2s (matching the setTimeout reset delay).
Gives the operator a clear visual cue that the scanner is about to reset. -->
<div class="w-full h-1.5 bg-success-200/40">
<div class="h-full bg-success-500 scanner-reset-countdown"></div>
<div class="bg-success-200/40 h-1.5 w-full">
<div class="bg-success-500 scanner-reset-countdown h-full">
</div>
</div>
</div>
{:else if scanning_status === 'error'}
<div class="card p-6 w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-error">
<div
class="card preset-tonal-error flex w-full max-w-md flex-col items-center space-y-4 p-6">
<CircleAlert size="3em" class="text-error-500" />
<p class="text-center font-bold">{error_msg}</p>
<button type="button" class="btn btn-sm preset-filled-error" onclick={reset_scanner}>
<button
type="button"
class="btn btn-sm preset-filled-error"
onclick={reset_scanner}>
<RefreshCw size="1em" />
Try Again
</button>
@@ -401,13 +456,17 @@
</div>
<style>
/* Countdown bar for the rapid-mode success card.
/* Countdown bar for the rapid-mode success card.
Must match the 2000ms setTimeout in reset_scanner(). */
.scanner-reset-countdown {
animation: scanner-reset-countdown 2s linear forwards;
.scanner-reset-countdown {
animation: scanner-reset-countdown 2s linear forwards;
}
@keyframes scanner-reset-countdown {
from {
width: 100%;
}
@keyframes scanner-reset-countdown {
from { width: 100%; }
to { width: 0%; }
to {
width: 0%;
}
</style>
}
</style>

View File

@@ -1,298 +1,353 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
* Multi-badge batch QR scanner.
*
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
* QR codes in a single camera frame. Staff lay 14 badges flat in view, tap
* "Capture Batch", and a grid of confirm cards appears — one per detected badge.
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
*
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
*
* Firefox: BarcodeDetector not yet supported — shows an informative fallback.
*/
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { SvelteMap } from 'svelte/reactivity';
import {
CircleCheck, Eye, Layers, LoaderCircle,
RefreshCw, RotateCcw, ScanLine, ShieldOff, UserPlus, X
} from '@lucide/svelte';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
* Multi-badge batch QR scanner.
*
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
* QR codes in a single camera frame. Staff lay 14 badges flat in view, tap
* "Capture Batch", and a grid of confirm cards appears — one per detected badge.
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
*
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
*
* Firefox: BarcodeDetector not yet supported — shows an informative fallback.
*/
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { SvelteMap } from 'svelte/reactivity';
import {
CircleCheck,
Eye,
Layers,
LoaderCircle,
RefreshCw,
RotateCcw,
ScanLine,
ShieldOff,
UserPlus,
X
} from '@lucide/svelte';
interface Props {
exhibit_id: string;
on_lead_added?: (badge: ae_EventBadge) => void;
}
interface Props {
exhibit_id: string;
on_lead_added?: (badge: ae_EventBadge) => void;
}
let { exhibit_id, on_lead_added }: Props = $props();
let { exhibit_id, on_lead_added }: Props = $props();
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
const is_supported = typeof window !== 'undefined' && 'BarcodeDetector' in window;
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
const is_supported =
typeof window !== 'undefined' && 'BarcodeDetector' in window;
// --- Types ---
type BatchStatus = 'loading' | 'ready' | 'blocked' | 'already_added' | 'reenable' | 'adding' | 'added' | 'error';
// --- Types ---
type BatchStatus =
| 'loading'
| 'ready'
| 'blocked'
| 'already_added'
| 'reenable'
| 'adding'
| 'added'
| 'error';
interface BatchItem {
id: string; // badge id_random from QR
badge: ae_EventBadge | null;
status: BatchStatus;
existing_tracking_id: string; // set when status === 'already_added'
dismissing: boolean; // true while CSS fade-out plays
}
interface BatchItem {
id: string; // badge id_random from QR
badge: ae_EventBadge | null;
status: BatchStatus;
existing_tracking_id: string; // set when status === 'already_added'
dismissing: boolean; // true while CSS fade-out plays
}
// --- Existing leads (duplicate detection) ---
// Value includes tracking_id AND enabled status so we can offer re-activation for removed leads.
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id').equals(exhibit_id).toArray();
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>();
leads.forEach(l => {
const b_id = l.event_badge_id?.toString();
if (b_id) map.set(b_id, {
// --- Existing leads (duplicate detection) ---
// Value includes tracking_id AND enabled status so we can offer re-activation for removed leads.
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
const map = new SvelteMap<
string,
{ tracking_id: string; enabled: boolean }
>();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString();
if (b_id)
map.set(b_id, {
tracking_id: l.event_exhibit_tracking_id?.toString() || '',
enabled: !!l.enable
});
});
return map;
})
);
});
return map;
})
);
// --- Camera ---
let video_el = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
let detector: any = null;
let camera_status = $state<'idle' | 'starting' | 'live' | 'capturing' | 'error'>('idle');
let camera_error = $state('');
// --- Camera ---
let video_el = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
let detector: any = null;
let camera_status = $state<
'idle' | 'starting' | 'live' | 'capturing' | 'error'
>('idle');
let camera_error = $state('');
// Start camera when the video element mounts
$effect(() => {
if (!video_el || !is_supported) return;
start_camera();
return () => stop_camera();
});
// Start camera when the video element mounts
$effect(() => {
if (!video_el || !is_supported) return;
start_camera();
return () => stop_camera();
});
onDestroy(stop_camera);
onDestroy(stop_camera);
async function start_camera() {
if (camera_status !== 'idle') return;
camera_status = 'starting';
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }
});
if (!video_el) { stop_camera(); return; }
video_el.srcObject = stream;
await video_el.play();
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
detector = new (window as any).BarcodeDetector({ formats: ['qr_code'] });
camera_status = 'live';
} catch (e: any) {
camera_status = 'error';
camera_error = e?.name === 'NotAllowedError'
async function start_camera() {
if (camera_status !== 'idle') return;
camera_status = 'starting';
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
if (!video_el) {
stop_camera();
return;
}
video_el.srcObject = stream;
await video_el.play();
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
detector = new (window as any).BarcodeDetector({
formats: ['qr_code']
});
camera_status = 'live';
} catch (e: any) {
camera_status = 'error';
camera_error =
e?.name === 'NotAllowedError'
? 'Camera access denied. Allow camera access and try again.'
: 'Could not start camera. Please try again.';
}
}
function stop_camera() {
stream?.getTracks().forEach((t) => t.stop());
stream = null;
detector = null;
if (camera_status !== 'error') camera_status = 'idle';
}
async function retry_camera() {
camera_status = 'idle';
await start_camera();
}
// --- Batch ---
let batch = $state<BatchItem[]>([]);
let ready_count = $derived(
batch.filter((i) => i.status === 'ready' && !i.dismissing).length
);
async function capture_batch() {
if (!detector || !video_el || camera_status !== 'live') return;
camera_status = 'capturing';
try {
const barcodes: Array<{ rawValue: string }> =
await detector.detect(video_el);
const existing_ids = new Set(batch.map((i) => i.id));
const new_objs = barcodes
.map((b) => ae_util.process_data_string(b.rawValue))
.filter(
(obj): obj is { type: string; id: string } =>
!!(obj && obj.type === 'event_badge' && obj.id)
)
.filter((obj) => !existing_ids.has(obj.id))
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
for (const obj of new_objs) {
const item: BatchItem = {
id: obj.id,
badge: null,
status: 'loading',
existing_tracking_id: '',
dismissing: false
};
batch.push(item);
load_badge(item); // fire-and-forget; updates item in place
}
} catch (e) {
console.error('[Multi] BarcodeDetector.detect failed:', e);
}
function stop_camera() {
stream?.getTracks().forEach(t => t.stop());
stream = null;
detector = null;
if (camera_status !== 'error') camera_status = 'idle';
}
camera_status = 'live';
}
async function retry_camera() {
camera_status = 'idle';
await start_camera();
}
async function load_badge(item: BatchItem) {
try {
const badge = await events_func.load_ae_obj_id__event_badge({
api_cfg: $ae_api,
event_badge_id: item.id,
log_lvl: 1
});
item.badge = badge;
// --- Batch ---
let batch = $state<BatchItem[]>([]);
let ready_count = $derived(batch.filter(i => i.status === 'ready' && !i.dismissing).length);
async function capture_batch() {
if (!detector || !video_el || camera_status !== 'live') return;
camera_status = 'capturing';
try {
const barcodes: Array<{ rawValue: string }> = await detector.detect(video_el);
const existing_ids = new Set(batch.map(i => i.id));
const new_objs = barcodes
.map(b => ae_util.process_data_string(b.rawValue))
.filter((obj): obj is { type: string; id: string } =>
!!(obj && obj.type === 'event_badge' && obj.id))
.filter(obj => !existing_ids.has(obj.id))
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
for (const obj of new_objs) {
const item: BatchItem = {
id: obj.id,
badge: null,
status: 'loading',
existing_tracking_id: '',
dismissing: false
};
batch.push(item);
load_badge(item); // fire-and-forget; updates item in place
}
} catch (e) {
console.error('[Multi] BarcodeDetector.detect failed:', e);
if ($existing_leads_map?.has(item.id)) {
const existing = $existing_leads_map.get(item.id)!;
item.existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
item.status = existing.enabled ? 'already_added' : 'reenable';
} else if (badge?.allow_tracking !== true) {
// Attendee has opted out — show card so staff can inform them
item.status = 'blocked';
} else {
item.status = 'ready';
}
camera_status = 'live';
} catch {
item.status = 'error';
}
}
async function load_badge(item: BatchItem) {
try {
const badge = await events_func.load_ae_obj_id__event_badge({
api_cfg: $ae_api,
event_badge_id: item.id,
log_lvl: 1
});
item.badge = badge;
async function add_lead(item: BatchItem) {
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
item.status = 'adding';
if ($existing_leads_map?.has(item.id)) {
const existing = $existing_leads_map.get(item.id)!;
item.existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
item.status = existing.enabled ? 'already_added' : 'reenable';
} else if (badge?.allow_tracking !== true) {
// Attendee has opted out — show card so staff can inform them
item.status = 'blocked';
} else {
item.status = 'ready';
}
} catch {
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email =
kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
event_badge_id: item.badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
item.status = 'added';
if (on_lead_added) on_lead_added(item.badge);
// Brief success display, then fade out
setTimeout(() => dismiss_item(item), 1000);
} else {
// API returned false/null — network error, API down, or auth failure.
item.status = 'error';
}
} catch {
item.status = 'error';
}
}
async function add_lead(item: BatchItem) {
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
item.status = 'adding';
async function reenable_lead(item: BatchItem) {
if (!item.existing_tracking_id) return;
item.status = 'adding';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
event_badge_id: item.badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
item.status = 'added';
if (on_lead_added) on_lead_added(item.badge);
// Brief success display, then fade out
setTimeout(() => dismiss_item(item), 1000);
} else {
// API returned false/null — network error, API down, or auth failure.
item.status = 'error';
}
} catch {
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: item.existing_tracking_id,
data: { enable: true }
});
if (result) {
item.status = 'added';
if (on_lead_added && item.badge) on_lead_added(item.badge);
setTimeout(() => dismiss_item(item), 1000);
} else {
item.status = 'error';
}
} catch {
item.status = 'error';
}
}
async function reenable_lead(item: BatchItem) {
if (!item.existing_tracking_id) return;
item.status = 'adding';
async function add_all() {
const to_add = batch.filter((i) => i.status === 'ready' && !i.dismissing);
await Promise.all(to_add.map(add_lead));
}
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: item.existing_tracking_id,
data: { enable: true }
});
if (result) {
item.status = 'added';
if (on_lead_added && item.badge) on_lead_added(item.badge);
setTimeout(() => dismiss_item(item), 1000);
} else {
item.status = 'error';
}
} catch {
item.status = 'error';
}
}
async function add_all() {
const to_add = batch.filter(i => i.status === 'ready' && !i.dismissing);
await Promise.all(to_add.map(add_lead));
}
function dismiss_item(item: BatchItem) {
item.dismissing = true;
// Remove from array after CSS transition completes (300ms)
setTimeout(() => {
const idx = batch.findIndex(i => i.id === item.id);
if (idx >= 0) batch.splice(idx, 1);
}, 350);
}
function dismiss_item(item: BatchItem) {
item.dismissing = true;
// Remove from array after CSS transition completes (300ms)
setTimeout(() => {
const idx = batch.findIndex((i) => i.id === item.id);
if (idx >= 0) batch.splice(idx, 1);
}, 350);
}
</script>
<div class="multi-scanner flex flex-col items-center gap-4 w-full">
<div class="multi-scanner flex w-full flex-col items-center gap-4">
{#if !is_supported}
<!-- Firefox / older browser fallback -->
<div class="card p-6 w-full max-w-md space-y-3 preset-tonal-warning text-center border-2 border-warning-500">
<Layers size="2.5em" class="mx-auto text-warning-500" />
<div
class="card preset-tonal-warning border-warning-500 w-full max-w-md space-y-3 border-2 p-6 text-center">
<Layers size="2.5em" class="text-warning-500 mx-auto" />
<h3 class="h4 font-bold">Multi-Scan Not Available</h3>
<p class="text-sm opacity-70">
Multi-scan uses the browser's BarcodeDetector API, which is supported in
Chrome, Edge, and Safari 17+. Firefox support is coming soon.
Multi-scan uses the browser's BarcodeDetector API, which is
supported in Chrome, Edge, and Safari 17+. Firefox support is
coming soon.
</p>
<p class="text-sm opacity-70">
Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.
</p>
<p class="text-sm opacity-70">Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.</p>
</div>
{:else}
<!-- Camera viewfinder — landscape 16:9 gives more horizontal coverage for multiple badges -->
<div class="w-full max-w-md relative rounded-xl overflow-hidden border-4 border-surface-500/20 shadow-xl bg-black aspect-video">
<div
class="border-surface-500/20 relative aspect-video w-full max-w-md overflow-hidden rounded-xl border-4 bg-black shadow-xl">
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={video_el}
class="w-full h-full object-cover"
class="h-full w-full object-cover"
playsinline
muted
></video>
muted></video>
<!-- Hint overlay: shown while camera is live, styled like a check-deposit scanner guide -->
{#if camera_status === 'live'}
<div class="absolute inset-0 pointer-events-none flex flex-col justify-end items-center pb-3 px-4">
<span class="bg-black/50 text-white text-xs font-semibold px-3 py-1.5 rounded-full tracking-wide text-center">
<div
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-end px-4 pb-3">
<span
class="rounded-full bg-black/50 px-3 py-1.5 text-center text-xs font-semibold tracking-wide text-white">
Align up to 4 badges flat in frame
</span>
</div>
<!-- Corner guides — visual aid for badge alignment -->
<div class="absolute inset-4 pointer-events-none">
<div class="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-primary-400/70 rounded-tl"></div>
<div class="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-primary-400/70 rounded-tr"></div>
<div class="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-primary-400/70 rounded-bl"></div>
<div class="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-primary-400/70 rounded-br"></div>
<div class="pointer-events-none absolute inset-4">
<div
class="border-primary-400/70 absolute top-0 left-0 h-6 w-6 rounded-tl border-t-2 border-l-2">
</div>
<div
class="border-primary-400/70 absolute top-0 right-0 h-6 w-6 rounded-tr border-t-2 border-r-2">
</div>
<div
class="border-primary-400/70 absolute bottom-0 left-0 h-6 w-6 rounded-bl border-b-2 border-l-2">
</div>
<div
class="border-primary-400/70 absolute right-0 bottom-0 h-6 w-6 rounded-br border-r-2 border-b-2">
</div>
</div>
{/if}
<!-- Starting overlay -->
{#if camera_status === 'starting'}
<div class="absolute inset-0 flex items-center justify-center bg-black/40">
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
<div
class="absolute inset-0 flex items-center justify-center bg-black/40">
<span
class="animate-pulse rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white shadow-lg">
Starting camera...
</span>
</div>
@@ -300,13 +355,16 @@
<!-- Error overlay -->
{#if camera_status === 'error'}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/80 rounded-xl p-6 text-center">
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{camera_error}</p>
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-4 rounded-xl bg-black/80 p-6 text-center">
<p
class="text-sm leading-snug font-semibold text-white drop-shadow">
{camera_error}
</p>
<button
type="button"
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2"
onclick={retry_camera}
>
class="text-surface-950 hover:bg-surface-100 flex cursor-pointer items-center gap-2 rounded-xl bg-white px-8 py-3 text-base font-bold shadow-lg transition-colors"
onclick={retry_camera}>
<RefreshCw size="1.2em" />
Try Again
</button>
@@ -318,10 +376,9 @@
{#if camera_status === 'live' || camera_status === 'capturing'}
<button
type="button"
class="w-full max-w-md rounded-xl py-4 font-bold text-base flex items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer disabled:opacity-50"
class="bg-primary-500 flex w-full max-w-md cursor-pointer items-center justify-center gap-2 rounded-xl py-4 text-base font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90 disabled:opacity-50"
disabled={camera_status === 'capturing'}
onclick={capture_batch}
>
onclick={capture_batch}>
{#if camera_status === 'capturing'}
<LoaderCircle class="animate-spin" size="1.3em" />
Scanning...
@@ -334,143 +391,172 @@
<!-- Badge grid -->
{#if batch.length > 0}
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{#each batch as item (item.id)}
<div
class="batch-card card p-4 space-y-3 bg-surface-50-900 border border-surface-500/20 shadow min-h-28"
class:dismissing={item.dismissing}
>
class="batch-card card bg-surface-50-900 border-surface-500/20 min-h-28 space-y-3 border p-4 shadow"
class:dismissing={item.dismissing}>
{#if item.status === 'loading'}
<!-- Skeleton — fixed height prevents layout bounce as badges load -->
<div class="space-y-2">
<div class="h-5 w-3/4 bg-surface-200-800 animate-pulse rounded"></div>
<div class="h-4 w-1/2 bg-surface-200-800 animate-pulse rounded"></div>
<div
class="bg-surface-200-800 h-5 w-3/4 animate-pulse rounded">
</div>
<div
class="bg-surface-200-800 h-4 w-1/2 animate-pulse rounded">
</div>
</div>
<div
class="bg-surface-200-800 h-9 animate-pulse rounded-lg">
</div>
<div class="h-9 bg-surface-200-800 animate-pulse rounded-lg"></div>
{:else if item.status === 'blocked'}
<!-- Tracking opt-out — show card so staff can inform the attendee -->
<div class="flex items-start gap-2">
<ShieldOff size="1.2em" class="text-warning-500 shrink-0 mt-0.5" />
<ShieldOff
size="1.2em"
class="text-warning-500 mt-0.5 shrink-0" />
<div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
<p class="text-xs opacity-60 mt-0.5">Opted out of lead scanning</p>
<p class="text-sm leading-tight font-bold">
{item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Opted out of lead scanning
</p>
</div>
</div>
<button
type="button"
class="w-full rounded-lg py-2 text-sm font-bold flex items-center justify-center gap-1.5 border border-warning-500/40 text-warning-600 dark:text-warning-400 hover:bg-warning-500/10 transition-colors cursor-pointer"
onclick={() => dismiss_item(item)}
>
class="border-warning-500/40 text-warning-600 dark:text-warning-400 hover:bg-warning-500/10 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border py-2 text-sm font-bold transition-colors"
onclick={() => dismiss_item(item)}>
<X size="1em" />
OK, Dismiss
</button>
{:else if item.status === 'already_added'}
<div class="flex items-start gap-2">
<CircleCheck size="1.2em" class="text-secondary-500 shrink-0 mt-0.5" />
<CircleCheck
size="1.2em"
class="text-secondary-500 mt-0.5 shrink-0" />
<div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
<p class="text-xs opacity-60 mt-0.5">Already captured</p>
<p class="text-sm leading-tight font-bold">
{item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Already captured
</p>
</div>
</div>
<div class="flex gap-2">
<a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${item.existing_tracking_id}`}
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors"
>
class="border-surface-500/40 hover:bg-surface-200-800 flex flex-1 items-center justify-center gap-1 rounded-lg border py-2 text-xs font-bold transition-colors">
<Eye size="0.9em" />
View
</a>
<button
type="button"
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer"
onclick={() => dismiss_item(item)}
>
class="border-surface-500/40 hover:bg-surface-200-800 flex flex-1 cursor-pointer items-center justify-center gap-1 rounded-lg border py-2 text-xs font-bold transition-colors"
onclick={() => dismiss_item(item)}>
<X size="0.9em" />
OK
</button>
</div>
{:else if item.status === 'reenable'}
<div class="flex items-start gap-2">
<RotateCcw size="1.2em" class="text-warning-500 shrink-0 mt-0.5" />
<RotateCcw
size="1.2em"
class="text-warning-500 mt-0.5 shrink-0" />
<div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
<p class="text-xs opacity-60 mt-0.5">Previously removed</p>
<p class="text-sm leading-tight font-bold">
{item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Previously removed
</p>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1.5 bg-warning-500 text-white hover:brightness-110 transition-all cursor-pointer"
onclick={() => reenable_lead(item)}
>
class="bg-warning-500 flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-bold text-white transition-all hover:brightness-110"
onclick={() => reenable_lead(item)}>
<RotateCcw size="0.9em" />
Re-activate
</button>
<button
type="button"
class="flex-none rounded-lg px-3 py-2 flex items-center justify-center border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-60"
class="border-surface-500/40 hover:bg-surface-200-800 flex flex-none cursor-pointer items-center justify-center rounded-lg border px-3 py-2 opacity-60 transition-colors"
title="Skip"
onclick={() => dismiss_item(item)}
>
onclick={() => dismiss_item(item)}>
<X size="1em" />
</button>
</div>
{:else if item.status === 'ready'}
<div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Badge Found'}</p>
<p class="text-xs opacity-60">{item.badge?.affiliations || ''}</p>
<p class="text-sm leading-tight font-bold">
{item.badge?.full_name || 'Badge Found'}
</p>
<p class="text-xs opacity-60">
{item.badge?.affiliations || ''}
</p>
</div>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg py-2.5 text-sm font-bold flex items-center justify-center gap-1.5 bg-primary-500 text-white hover:brightness-110 transition-all cursor-pointer"
onclick={() => add_lead(item)}
>
class="bg-primary-500 flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2.5 text-sm font-bold text-white transition-all hover:brightness-110"
onclick={() => add_lead(item)}>
<UserPlus size="1em" />
Add
</button>
<button
type="button"
class="flex-none rounded-lg px-3 py-2.5 flex items-center justify-center border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-60"
class="border-surface-500/40 hover:bg-surface-200-800 flex flex-none cursor-pointer items-center justify-center rounded-lg border px-3 py-2.5 opacity-60 transition-colors"
title="Skip this badge"
onclick={() => dismiss_item(item)}
>
onclick={() => dismiss_item(item)}>
<X size="1em" />
</button>
</div>
{:else if item.status === 'adding'}
<div class="flex items-center gap-2 py-1 opacity-70">
<LoaderCircle size="1.2em" class="animate-spin text-primary-500 shrink-0" />
<div
class="flex items-center gap-2 py-1 opacity-70">
<LoaderCircle
size="1.2em"
class="text-primary-500 shrink-0 animate-spin" />
<div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || '...'}</p>
<p class="text-sm leading-tight font-bold">
{item.badge?.full_name || '...'}
</p>
<p class="text-xs opacity-60">Adding...</p>
</div>
</div>
{:else if item.status === 'added'}
<div class="flex items-center gap-2 py-1">
<CircleCheck size="1.2em" class="text-success-500 shrink-0" />
<CircleCheck
size="1.2em"
class="text-success-500 shrink-0" />
<div>
<p class="font-bold text-sm leading-tight text-success-600 dark:text-success-400">{item.badge?.full_name || 'Lead'}</p>
<p class="text-xs opacity-60">Lead added!</p>
<p
class="text-success-600 dark:text-success-400 text-sm leading-tight font-bold">
{item.badge?.full_name || 'Lead'}
</p>
<p class="text-xs opacity-60">
Lead added!
</p>
</div>
</div>
{:else if item.status === 'error'}
<div>
<p class="text-sm font-bold text-error-600 dark:text-error-400">Failed to add</p>
<p class="text-xs opacity-60">{item.badge?.full_name || 'Unknown'}</p>
<p
class="text-error-600 dark:text-error-400 text-sm font-bold">
Failed to add
</p>
<p class="text-xs opacity-60">
{item.badge?.full_name || 'Unknown'}
</p>
</div>
<button
type="button"
class="w-full rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1.5 border border-error-500/40 text-error-600 dark:text-error-400 hover:bg-error-500/10 transition-colors cursor-pointer"
onclick={() => dismiss_item(item)}
>
class="border-error-500/40 text-error-600 dark:text-error-400 hover:bg-error-500/10 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border py-2 text-xs font-bold transition-colors"
onclick={() => dismiss_item(item)}>
<X size="1em" />
Dismiss
</button>
@@ -483,27 +569,27 @@
{#if ready_count > 0}
<button
type="button"
class="w-full rounded-xl py-4 font-bold text-base flex items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={add_all}
>
class="bg-primary-500 flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl py-4 text-base font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={add_all}>
<UserPlus size="1.3em" />
Add All ({ready_count})
</button>
{/if}
{/if}
{/if}
</div>
<style>
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
Duration matches the 350ms setTimeout in dismiss_item(). */
.batch-card {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.batch-card.dismissing {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}
.batch-card {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.batch-card.dismissing {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}
</style>

View File

@@ -1,81 +1,97 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
* Tab 2: Add - Search / QR Scan Layout.
*
* Two orthogonal toggles:
* - mode: 'qr' | 'search' — how to find the attendee
* - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only)
* rapid: confirm tap → auto-reset → scan next person immediately
* qualify: confirm tap → navigate to lead detail → fill qualifiers/notes
* auto: no confirm — badge is added immediately on scan → auto-reset
* multi: BarcodeDetector batch scan → grid of confirm cards
*/
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
import { events_loc } from '$lib/stores/ae_events_stores';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
* Tab 2: Add - Search / QR Scan Layout.
*
* Two orthogonal toggles:
* - mode: 'qr' | 'search' — how to find the attendee
* - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only)
* rapid: confirm tap → auto-reset → scan next person immediately
* qualify: confirm tap → navigate to lead detail → fill qualifiers/notes
* auto: no confirm — badge is added immediately on scan → auto-reset
* multi: BarcodeDetector batch scan → grid of confirm cards
*/
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
import { events_loc } from '$lib/stores/ae_events_stores';
interface Props {
exhibit_id: string;
}
interface Props {
exhibit_id: string;
}
let { exhibit_id }: Props = $props();
let { exhibit_id }: Props = $props();
// QR vs Manual Search (persisted per exhibit)
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
// QR vs Manual Search (persisted per exhibit)
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
function set_mode(new_mode: string) {
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
}
function set_mode(new_mode: string) {
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
}
// Scan qualify mode (persisted per exhibit)
// Scan qualify mode (persisted per exhibit)
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
let scan_qualify = $derived.by(() => {
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
let scan_qualify = $derived.by(() => {
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
});
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
});
function set_scan_qualify(new_mode: ScanQualifyMode) {
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {};
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
show_mode_opts = false;
function set_scan_qualify(new_mode: ScanQualifyMode) {
if (!$events_loc.leads.tab_scan_qualify)
$events_loc.leads.tab_scan_qualify = {};
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
show_mode_opts = false;
}
function handle_lead_added(badge: any) {
$events_loc.leads.tracking__search_version++;
}
// Mode selector expand/collapse
let show_mode_opts = $state(false);
// Mode config — drives both the trigger display and the options grid
const qr_modes: Array<{
value: ScanQualifyMode;
label: string;
desc: string;
icon: any;
}> = [
{
value: 'rapid',
label: 'Confirm',
desc: 'Tap Add & Scan or Add & View',
icon: Zap
},
{
value: 'auto',
label: 'Auto',
desc: 'Auto-add · no tap needed',
icon: Bot
},
{
value: 'multi',
label: 'Multi',
desc: 'Batch scan up to 4 badges',
icon: Layers
}
];
function handle_lead_added(badge: any) {
$events_loc.leads.tracking__search_version++;
}
// Mode selector expand/collapse
let show_mode_opts = $state(false);
// Mode config — drives both the trigger display and the options grid
const qr_modes: Array<{
value: ScanQualifyMode;
label: string;
desc: string;
icon: any;
}> = [
{ value: 'rapid', label: 'Confirm', desc: 'Tap Add & Scan or Add & View', icon: Zap },
{ value: 'auto', label: 'Auto', desc: 'Auto-add · no tap needed', icon: Bot },
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers },
];
let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]);
let active_mode = $derived(
qr_modes.find((m) => m.value === scan_qualify) ?? qr_modes[0]
);
</script>
<div class="ae-tab-add flex flex-col items-center gap-3 w-full mx-auto">
<div class="ae-tab-add mx-auto flex w-full flex-col items-center gap-3">
<!-- QR / Search toggle -->
<button
type="button"
class="btn btn-sm preset-filled-secondary font-bold shadow-sm px-4 py-2.5 flex items-center gap-2 w-full transition-all"
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}
>
class="btn btn-sm preset-filled-secondary flex w-full items-center gap-2 px-4 py-2.5 font-bold shadow-sm transition-all"
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}>
{#if mode === 'qr'}
<Search size="1.1em" />
<span>Switch to Manual Search</span>
@@ -88,60 +104,61 @@
<!-- Scan mode selector (QR mode only) -->
{#if mode === 'qr'}
<div class="w-full">
<!-- Trigger: shows active mode, tapping expands options -->
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-100-900 border border-surface-500/20 shadow-sm transition-colors cursor-pointer"
onclick={() => show_mode_opts = !show_mode_opts}
title="Change scan mode"
>
class="bg-surface-100-900 border-surface-500/20 flex w-full cursor-pointer items-center gap-3 rounded-xl border px-4 py-3 shadow-sm transition-colors"
onclick={() => (show_mode_opts = !show_mode_opts)}
title="Change scan mode">
<!-- Colored icon pill -->
<span class="p-1.5 rounded-lg shrink-0 text-white"
<span
class="shrink-0 rounded-lg p-1.5 text-white"
class:bg-primary-500={scan_qualify === 'rapid'}
class:bg-tertiary-500={scan_qualify === 'auto'}
class:bg-warning-500={scan_qualify === 'multi'}
>
class:bg-warning-500={scan_qualify === 'multi'}>
<active_mode.icon size="1em" />
</span>
<!-- Mode name + description -->
<div class="flex-1 text-left">
<span class="font-bold text-sm">{active_mode.label}</span>
<span class="text-xs opacity-50 ml-2">{active_mode.desc}</span>
<span class="text-sm font-bold">{active_mode.label}</span>
<span class="ml-2 text-xs opacity-50"
>{active_mode.desc}</span>
</div>
<!-- Chevron -->
<ChevronDown
size="1.1em"
class="opacity-40 transition-transform duration-200 {show_mode_opts ? 'rotate-180' : ''}"
/>
class="opacity-40 transition-transform duration-200 {show_mode_opts
? 'rotate-180'
: ''}" />
</button>
<!-- Options grid (2×2) — shown when trigger is tapped -->
{#if show_mode_opts}
<div class="mt-1.5 grid grid-cols-3 gap-2 p-2 bg-surface-50-900 rounded-xl border border-surface-500/20 shadow-lg">
<div
class="bg-surface-50-900 border-surface-500/20 mt-1.5 grid grid-cols-3 gap-2 rounded-xl border p-2 shadow-lg">
{#each qr_modes as m}
<button
type="button"
class="flex flex-col items-center gap-1.5 p-3 rounded-lg text-center transition-all cursor-pointer"
class="flex cursor-pointer flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-all"
class:bg-surface-100-900={scan_qualify !== m.value}
class:opacity-50={scan_qualify !== m.value}
class:bg-surface-50-900={scan_qualify === m.value}
class:shadow={scan_qualify === m.value}
class:ring-1={scan_qualify === m.value}
class:ring-surface-500={scan_qualify === m.value}
onclick={() => set_scan_qualify(m.value)}
>
<span class="p-1.5 rounded-lg text-white"
onclick={() => set_scan_qualify(m.value)}>
<span
class="rounded-lg p-1.5 text-white"
class:bg-primary-500={m.value === 'rapid'}
class:bg-tertiary-500={m.value === 'auto'}
class:bg-warning-500={m.value === 'multi'}
>
class:bg-warning-500={m.value === 'multi'}>
<m.icon size="1.1em" />
</span>
<span class="font-bold text-sm">{m.label}</span>
<span class="text-[10px] opacity-60 leading-tight">{m.desc}</span>
<span class="text-sm font-bold">{m.label}</span>
<span class="text-[10px] leading-tight opacity-60"
>{m.desc}</span>
</button>
{/each}
</div>
@@ -150,15 +167,22 @@
{/if}
<!-- Content Area -->
<div class="w-full flex flex-col items-center min-h-100">
<div class="flex min-h-100 w-full flex-col items-center">
{#if mode === 'qr'}
{#if scan_qualify === 'multi'}
<Comp_lead_qr_scanner_multi {exhibit_id} on_lead_added={handle_lead_added} />
<Comp_lead_qr_scanner_multi
{exhibit_id}
on_lead_added={handle_lead_added} />
{:else}
<Comp_lead_qr_scanner {exhibit_id} {scan_qualify} on_lead_added={handle_lead_added} />
<Comp_lead_qr_scanner
{exhibit_id}
{scan_qualify}
on_lead_added={handle_lead_added} />
{/if}
{:else}
<Comp_lead_manual_search {exhibit_id} on_lead_added={handle_lead_added} />
<Comp_lead_manual_search
{exhibit_id}
on_lead_added={handle_lead_added} />
{/if}
</div>
</div>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte
* Tab 3: Leads List Stub.
*/
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte
* Tab 3: Leads List Stub.
*/
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
interface Props {
lq__event_exhibit_tracking_obj_li: any;
}
let { lq__event_exhibit_tracking_obj_li }: Props = $props();
interface Props {
lq__event_exhibit_tracking_obj_li: any;
}
let { lq__event_exhibit_tracking_obj_li }: Props = $props();
</script>
<div class="ae-tab-list p-4 space-y-4">
<div class="ae-tab-list space-y-4 p-4">
<h3 class="h3">Captured Leads</h3>
<Comp_exhibit_tracking_obj_li {lq__event_exhibit_tracking_obj_li} />
</div>

View File

@@ -1,74 +1,106 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte
* Tab 4: Manage/Config - Exhibitor Settings and Profile.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
import Comp_exhibit_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
import { ChevronDown, ChevronRight, Clock, CreditCard, Database, Info, Key, Lock, LogOut, MessageSquare, RefreshCw, Settings, Store, UserX, Users } from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte
* Tab 4: Manage/Config - Exhibitor Settings and Profile.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
import Comp_exhibit_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
import {
ChevronDown,
ChevronRight,
Clock,
CreditCard,
Database,
Info,
Key,
Lock,
LogOut,
MessageSquare,
RefreshCw,
Settings,
Store,
UserX,
Users
} from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
// Track local status for specific actions
let updating = $state(false);
let show_license_mgmt = $state(false);
let show_custom_questions = $state(false);
let show_billing = $state(false);
// Track local status for specific actions
let updating = $state(false);
let show_license_mgmt = $state(false);
let show_custom_questions = $state(false);
let show_billing = $state(false);
function handle_signout() {
if (confirm('Sign out from this booth?')) {
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
$events_sess.leads.entered_passcode = null;
// Navigate to start tab
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = 'start';
}
function handle_signout() {
if (confirm('Sign out from this booth?')) {
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
$events_sess.leads.entered_passcode = null;
// Navigate to start tab
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = 'start';
}
}
</script>
<div class="ae-tab-manage w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300 pb-20">
<div
class="ae-tab-manage animate-in fade-in slide-in-from-bottom-2 w-full space-y-8 pb-20 duration-300">
<!-- Section: Admin Tools (Manager Access Only) -->
{#if $ae_loc.manager_access}
<section class="space-y-4 p-4 border-2 border-primary-500/20 rounded-xl bg-primary-500/5">
<div class="flex items-center gap-2 border-b border-primary-500/10 pb-2">
<section
class="border-primary-500/20 bg-primary-500/5 space-y-4 rounded-xl border-2 p-4">
<div
class="border-primary-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider text-primary-500">Admin Tools</h3>
<h3
class="text-primary-500 text-lg font-bold tracking-wider uppercase">
Admin Tools
</h3>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Priority / Payment Toggle -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40">Payment Status</div>
<div
class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Payment Status
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="priority"
field_type="checkbox"
current_value={$lq__exhibit_obj?.priority}
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
>
<div class="font-bold">{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}</div>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})}>
<div class="font-bold">
{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}
</div>
</Element_ae_obj_field_editor>
</div>
<!-- Max Licenses -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40">Max Licenses</div>
<div
class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Max Licenses
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
@@ -76,13 +108,19 @@
field_type="number"
current_value={$lq__exhibit_obj?.license_max}
class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
<!-- Small Devices -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40">Small Devices</div>
<div
class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Small Devices
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
@@ -90,13 +128,19 @@
field_type="number"
current_value={$lq__exhibit_obj?.leads_device_sm_qty}
class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
<!-- Large Devices -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40">Large Devices</div>
<div
class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Large Devices
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
@@ -104,8 +148,11 @@
field_type="number"
current_value={$lq__exhibit_obj?.leads_device_lg_qty}
class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
</div>
</section>
@@ -113,16 +160,21 @@
<!-- Section: Booth Profile -->
<section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Store size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Booth Profile</h3>
<h3 class="text-lg font-bold tracking-wider uppercase">
Booth Profile
</h3>
</div>
<div class="grid grid-cols-1 gap-6">
<!-- Name -->
<div class="card p-4 preset-tonal-surface shadow-sm">
<div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2">
<span class="text-xs uppercase font-black opacity-40 tracking-widest">Exhibitor Name</span>
<span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Exhibitor Name</span>
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
@@ -132,15 +184,23 @@
current_value={$lq__exhibit_obj?.name}
display_block={true}
class_li="font-bold text-xl"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
<p class="text-[10px] opacity-50 mt-2 italic">This name is visible to attendees when you scan their badges.</p>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
<p class="mt-2 text-[10px] italic opacity-50">
This name is visible to attendees when you scan their
badges.
</p>
</div>
<!-- Description -->
<div class="card p-4 preset-tonal-surface shadow-sm">
<div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2">
<span class="text-xs uppercase font-black opacity-40 tracking-widest">Booth Description / Promo</span>
<span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Booth Description / Promo</span>
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
@@ -150,29 +210,39 @@
current_value={$lq__exhibit_obj?.description}
display_block={true}
class_li="text-sm"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
</div>
</section>
<!-- Section: Staff Access -->
<section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Lock size="1.2em" class="text-warning-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Access & Security</h3>
<h3 class="text-lg font-bold tracking-wider uppercase">
Access & Security
</h3>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Staff Passcode -->
<div class="card p-4 bg-surface-500/5 border border-surface-500/10">
<div class="card bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1">Staff Passcode</div>
<div
class="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Staff Passcode
</div>
<!-- Add a clear read-only display for admins to see the code at a glance -->
{#if $ae_loc.administrator_access}
<div class="font-mono text-xl tracking-widest font-bold text-primary-500 mb-2">
<div
class="text-primary-500 mb-2 font-mono text-xl font-bold tracking-widest">
{$lq__exhibit_obj?.staff_passcode || '----'}
</div>
{/if}
@@ -185,33 +255,44 @@
current_value={$lq__exhibit_obj?.staff_passcode}
display_block={true}
class_li="font-mono text-xl tracking-widest font-bold"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
/>
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
<Key size="1.5em" class="opacity-20" />
</div>
<p class="text-[9px] opacity-40 mt-2 italic">Shared code for your team to sign in to this booth.</p>
<p class="mt-2 text-[9px] italic opacity-40">
Shared code for your team to sign in to this booth.
</p>
</div>
<!-- Booth Code -->
<div class="card p-4 bg-surface-500/5 border border-surface-500/10">
<div class="card bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between">
<div>
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Booth Identifier</div>
<div class="font-mono text-xl font-bold">#{$lq__exhibit_obj?.code || 'N/A'}</div>
<div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Booth Identifier
</div>
<div class="font-mono text-xl font-bold">
#{$lq__exhibit_obj?.code || 'N/A'}
</div>
</div>
<Info size="1.5em" class="opacity-20" />
</div>
<p class="text-[9px] opacity-40 mt-2 italic">Official floor plan booth number.</p>
<p class="mt-2 text-[9px] italic opacity-40">
Official floor plan booth number.
</p>
</div>
</div>
<!-- Sign Out -->
{#if !$ae_loc.manager_access}
<button
class="btn preset-outlined-error w-full mt-2"
onclick={handle_signout}
>
class="btn preset-outlined-error mt-2 w-full"
onclick={handle_signout}>
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
</button>
{/if}
@@ -219,43 +300,57 @@
<!-- Section: Lead Settings -->
<section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Lead Retrieval Config</h3>
<h3 class="text-lg font-bold tracking-wider uppercase">
Lead Retrieval Config
</h3>
</div>
<div class="card p-0 divide-y divide-surface-500/10 overflow-hidden shadow-md">
<div
class="card divide-surface-500/10 divide-y overflow-hidden p-0 shadow-md">
<!-- Licenses — visible to: Aether admins OR someone signed in with the shared exhibit passcode.
Spec: "A client staff (Trusted Access or above) or someone signed in with an Exhibit passcode
can add/edit/remove licenses." — PROJECT__AE_Events_Exhibitor_Leads_v3.md -->
{#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'}
<div class="p-0">
<button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
onclick={() => show_license_mgmt = !show_license_mgmt}
>
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() =>
(show_license_mgmt = !show_license_mgmt)}>
<div class="flex items-center gap-4">
<div class="bg-primary-500/10 p-2 rounded-lg text-primary-500"><Users size="1.2em" /></div>
<div
class="bg-primary-500/10 text-primary-500 rounded-lg p-2">
<Users size="1.2em" />
</div>
<div class="text-left">
<div class="font-bold text-sm">Exhibit Leads Licensees</div>
<div class="text-xs opacity-50">Manage assigned users and codes</div>
<div class="text-sm font-bold">
Exhibit Leads Licensees
</div>
<div class="text-xs opacity-50">
Manage assigned users and codes
</div>
</div>
</div>
{#if show_license_mgmt}
<ChevronDown size="1.2em" class="opacity-20" />
{:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" />
<ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if}
</button>
{#if show_license_mgmt}
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
<div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_license_list
{exhibit_id}
event_id={page.params.event_id ?? ''}
license_li_json={$lq__exhibit_obj?.license_li_json ?? '[]'}
license_max={$lq__exhibit_obj?.license_max}
/>
license_li_json={$lq__exhibit_obj?.license_li_json ??
'[]'}
license_max={$lq__exhibit_obj?.license_max} />
</div>
{/if}
</div>
@@ -264,30 +359,40 @@
<!-- Custom Questions -->
<div class="p-0">
<button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
onclick={() => show_custom_questions = !show_custom_questions}
>
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() =>
(show_custom_questions = !show_custom_questions)}>
<div class="flex items-center gap-4">
<div class="bg-secondary-500/10 p-2 rounded-lg text-secondary-500"><MessageSquare size="1.2em" /></div>
<div
class="bg-secondary-500/10 text-secondary-500 rounded-lg p-2">
<MessageSquare size="1.2em" />
</div>
<div class="text-left">
<div class="font-bold text-sm">Qualifiers & Questions</div>
<div class="text-xs opacity-50">Configure lead capture follow-up responses</div>
<div class="text-sm font-bold">
Qualifiers & Questions
</div>
<div class="text-xs opacity-50">
Configure lead capture follow-up responses
</div>
</div>
</div>
{#if show_custom_questions}
<ChevronDown size="1.2em" class="opacity-20" />
{:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" />
<ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if}
</button>
{#if show_custom_questions}
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
<div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_custom_questions
{exhibit_id}
event_id={page.params.event_id ?? ''}
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
/>
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
'[]'} />
</div>
{/if}
</div>
@@ -295,25 +400,34 @@
<!-- Billing -->
<div class="p-0">
<button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
onclick={() => show_billing = !show_billing}
>
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() => (show_billing = !show_billing)}>
<div class="flex items-center gap-4">
<div class="bg-success-500/10 p-2 rounded-lg text-success-500"><CreditCard size="1.2em" /></div>
<div
class="bg-success-500/10 text-success-500 rounded-lg p-2">
<CreditCard size="1.2em" />
</div>
<div class="text-left">
<div class="font-bold text-sm">Licenses & Billing</div>
<div class="text-xs opacity-50">Review licenses and manage payment</div>
<div class="text-sm font-bold">
Licenses & Billing
</div>
<div class="text-xs opacity-50">
Review licenses and manage payment
</div>
</div>
</div>
{#if show_billing}
<ChevronDown size="1.2em" class="opacity-20" />
{:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" />
<ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if}
</button>
{#if show_billing}
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
<div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_payment />
</div>
{/if}
@@ -323,28 +437,46 @@
<!-- Section: App Settings -->
<section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">App Settings</h3>
<h3 class="text-lg font-bold tracking-wider uppercase">
App Settings
</h3>
</div>
<div class="card p-4 space-y-6 preset-tonal-surface shadow-inner">
<div class="card preset-tonal-surface space-y-6 p-4 shadow-inner">
<!-- Interface Prefs -->
<div class="space-y-3">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Interface Preferences</div>
<div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Interface Preferences
</div>
<div class="grid grid-cols-1 gap-2">
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors">
<label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Auto-hide Header/Footer</span>
<input type="checkbox" class="checkbox" bind:checked={$ae_loc.auto_hide_nav} />
<input
type="checkbox"
class="checkbox"
bind:checked={$ae_loc.auto_hide_nav} />
</label>
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors">
<label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Payment Tab</span>
<input type="checkbox" class="checkbox" bind:checked={$ae_loc.show_leads_payment} />
<input
type="checkbox"
class="checkbox"
bind:checked={$ae_loc.show_leads_payment} />
</label>
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors">
<label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Extra Details</span>
<input type="checkbox" class="checkbox" bind:checked={$events_loc.show_details} />
<input
type="checkbox"
class="checkbox"
bind:checked={$events_loc.show_details} />
</label>
</div>
</div>
@@ -352,56 +484,81 @@
<!-- List Refresh -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Data Synchronization</div>
<div class="flex items-center gap-2 text-[10px] font-mono opacity-60">
<div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Data Synchronization
</div>
<div
class="flex items-center gap-2 font-mono text-[10px] opacity-60">
<Clock size="1em" />
{#if $events_sess.leads.last_refresh_time}
Last: {new Date($events_sess.leads.last_refresh_time).toLocaleTimeString()}
Last: {new Date(
$events_sess.leads.last_refresh_time
).toLocaleTimeString()}
{:else}
Waiting...
{/if}
</div>
</div>
<div class="flex items-center gap-4 p-2 bg-surface-500/5 rounded-lg border border-surface-500/10">
<div
class="bg-surface-500/5 border-surface-500/10 flex items-center gap-4 rounded-lg border p-2">
<div class="flex-1 space-y-1">
<span class="text-sm block">Refresh Interval (sec)</span>
<div class="text-[9px] opacity-40 uppercase font-bold">
Next Sync in <span class="text-primary-500">{$events_sess.leads.next_refresh_countdown}s</span>
<span class="block text-sm"
>Refresh Interval (sec)</span>
<div class="text-[9px] font-bold uppercase opacity-40">
Next Sync in <span class="text-primary-500"
>{$events_sess.leads
.next_refresh_countdown}s</span>
</div>
</div>
<input
type="number"
class="input w-20 text-right font-mono p-1 bg-transparent border-b border-surface-500/20"
class="input border-surface-500/20 w-20 border-b bg-transparent p-1 text-right font-mono"
min="1"
max="120"
bind:value={$events_loc.leads.refresh_interval_sec}
placeholder="25"
/>
placeholder="25" />
</div>
</div>
<!-- Maintenance -->
<div class="space-y-3">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Maintenance & Reset</div>
<div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Maintenance & Reset
</div>
<div class="grid grid-cols-2 gap-2">
<button class="btn btn-sm preset-filled-warning" onclick={() => window.location.reload()}>
<button
class="btn btn-sm preset-filled-warning"
onclick={() => window.location.reload()}>
<RefreshCw size="1em" class="mr-2" /> Reload App
</button>
<button class="btn btn-sm preset-outlined-error" onclick={() => {
if(confirm('Clear all local cached data (IDB)?')) {
db_events.delete().then(() => window.location.reload());
}
}}>
<button
class="btn btn-sm preset-outlined-error"
onclick={() => {
if (confirm('Clear all local cached data (IDB)?')) {
db_events
.delete()
.then(() => window.location.reload());
}
}}>
<Database size="1em" class="mr-2" /> Clear IDB
</button>
<button class="btn btn-sm preset-outlined-error col-span-2" onclick={() => {
if(confirm('Reset all local app settings and sign out?')) {
localStorage.clear();
window.location.reload();
}
}}>
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard Reset)
<button
class="btn btn-sm preset-outlined-error col-span-2"
onclick={() => {
if (
confirm(
'Reset all local app settings and sign out?'
)
) {
localStorage.clear();
window.location.reload();
}
}}>
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard
Reset)
</button>
</div>
</div>
@@ -409,13 +566,14 @@
</section>
<!-- Help Footer -->
<div class="pt-10 pb-20 text-center space-y-2 opacity-40">
<div class="space-y-2 pt-10 pb-20 text-center opacity-40">
<p class="text-xs">Exhibitor Management Module v3.0</p>
<p class="text-[10px] font-mono">Exhibit ID: {$lq__exhibit_obj?.event_exhibit_id}</p>
<p class="font-mono text-[10px]">
Exhibit ID: {$lq__exhibit_obj?.event_exhibit_id}
</p>
</div>
</div>
<style lang="postcss">
/* Custom tab styles if needed */
/* Custom tab styles if needed */
</style>

View File

@@ -1,69 +1,85 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte
* Tab 1: Start / Sign In / Welcome.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte';
import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte';
import { CircleCheck, LayoutGrid, ShieldCheck, UserCheck } from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte
* Tab 1: Start / Sign In / Welcome.
*/
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte';
import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte';
import {
CircleCheck,
LayoutGrid,
ShieldCheck,
UserCheck
} from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
</script>
<div class="ae-tab-start w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
class="ae-tab-start animate-in fade-in slide-in-from-bottom-2 w-full space-y-8 duration-300">
<!-- Hero / Welcome Section -->
<section class="text-center space-y-4 py-6">
<div class="inline-flex p-4 rounded-full bg-primary-500/10 text-primary-500 mb-2">
<section class="space-y-4 py-6 text-center">
<div
class="bg-primary-500/10 text-primary-500 mb-2 inline-flex rounded-full p-4">
<LayoutGrid size="3em" />
</div>
<h2 class="text-3xl font-black tracking-tight">
Welcome to the<br />
<span class="text-primary-500">Exhibitor Portal</span>
</h2>
<p class="text-lg opacity-60 max-w-md mx-auto">
Ready to capture leads for <span class="font-bold text-surface-900-100">{$lq__exhibit_obj?.name || 'this exhibit'}</span>?
<p class="mx-auto max-w-md text-lg opacity-60">
Ready to capture leads for <span
class="text-surface-900-100 font-bold"
>{$lq__exhibit_obj?.name || 'this exhibit'}</span
>?
</p>
</section>
<!-- Features Grid (Compact) -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto px-4">
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface">
<div class="mx-auto grid max-w-2xl grid-cols-1 gap-4 px-4 sm:grid-cols-3">
<div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<CircleCheck size="1.5em" class="text-success-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Fast Capture</span>
<span class="text-xs font-bold tracking-wider uppercase"
>Fast Capture</span>
</div>
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface">
<div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<UserCheck size="1.5em" class="text-secondary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Staff IDs</span>
<span class="text-xs font-bold tracking-wider uppercase"
>Staff IDs</span>
</div>
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface">
<div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<ShieldCheck size="1.5em" class="text-primary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Secure Sync</span>
<span class="text-xs font-bold tracking-wider uppercase"
>Secure Sync</span>
</div>
</div>
<!-- PWA Install Nudge — shown between feature highlights and sign-in -->
<div class="w-full max-w-md mx-auto px-4">
<div class="mx-auto w-full max-w-md px-4">
<Element_pwa_install_prompt />
</div>
<!-- Sign In Area -->
<div class="w-full max-w-md mx-auto">
<div class="mx-auto w-full max-w-md">
<Comp_exhibit_signin />
</div>
<!-- Info Footer -->
<div class="text-center pt-8 opacity-40">
<p class="text-[10px] uppercase font-black tracking-[0.2em]">Powered by Aether Platform</p>
<div class="pt-8 text-center opacity-40">
<p class="text-[10px] font-black tracking-[0.2em] uppercase">
Powered by Aether Platform
</p>
</div>
</div>
</div>

View File

@@ -1,98 +1,117 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte
* Lead Detail View - Basic Read-Only version.
*/
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
import { Briefcase, CalendarDays, ChevronLeft, Eye, FileText, ListTodo, LoaderCircle, Mail, MapPin, RotateCcw, ShieldCheck, SquarePen, Star, Store, Trash2, User } from '@lucide/svelte';
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
/**
* src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte
* Lead Detail View - Basic Read-Only version.
*/
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
import {
Briefcase,
CalendarDays,
ChevronLeft,
Eye,
FileText,
ListTodo,
LoaderCircle,
Mail,
MapPin,
RotateCcw,
ShieldCheck,
SquarePen,
Star,
Store,
Trash2,
User
} from '@lucide/svelte';
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
let lq__lead_obj = $derived(
liveQuery(async () => {
if (!exhibit_tracking_id) return null;
return await db_events.exhibit_tracking.get(exhibit_tracking_id);
})
);
let lq__lead_obj = $derived(
liveQuery(async () => {
if (!exhibit_tracking_id) return null;
return await db_events.exhibit_tracking.get(exhibit_tracking_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
let lq__exhibit_obj = $derived(
liveQuery(async () => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id);
})
);
let is_edit_mode = $state(false);
let is_edit_mode = $state(false);
// Remove / Restore flow.
// Two-click confirm for remove: idle → confirm → removing → (navigate back).
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>('idle');
// Remove / Restore flow.
// Two-click confirm for remove: idle → confirm → removing → (navigate back).
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>(
'idle'
);
async function remove_lead() {
const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return;
remove_status = 'removing';
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: eid,
exhibit_tracking_id,
data: { enable: false }
});
// Navigate back to exhibit leads list after removal
goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`);
} catch {
// If update fails, reset so user can try again
remove_status = 'idle';
}
async function remove_lead() {
const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return;
remove_status = 'removing';
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: eid,
exhibit_tracking_id,
data: { enable: false }
});
// Navigate back to exhibit leads list after removal
goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`);
} catch {
// If update fails, reset so user can try again
remove_status = 'idle';
}
}
async function restore_lead() {
const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return;
remove_status = 'restoring';
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: eid,
exhibit_tracking_id,
data: { enable: true }
});
remove_status = 'idle';
} catch {
remove_status = 'idle';
}
async function restore_lead() {
const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return;
remove_status = 'restoring';
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: eid,
exhibit_tracking_id,
data: { enable: true }
});
remove_status = 'idle';
} catch {
remove_status = 'idle';
}
}
// Helper to format date using Aether utility
function format_date(date: any) {
if (!date) return '';
return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds');
}
// Helper to format date using Aether utility
function format_date(date: any) {
if (!date) return '';
return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds');
}
</script>
<svelte:head>
<title>Lead: {$lq__lead_obj?.event_badge_full_name ?? 'Loading...'}</title>
</svelte:head>
<section class="lead-detail-view w-full flex flex-col items-center">
<section class="lead-detail-view flex w-full flex-col items-center">
<!-- Local Header -->
<header class="w-full bg-surface-100-900 border-b border-surface-500/20 px-4 py-3 sticky top-0 z-10 flex items-center justify-between gap-4 shadow-sm">
<header
class="bg-surface-100-900 border-surface-500/20 sticky top-0 z-10 flex w-full items-center justify-between gap-4 border-b px-4 py-3 shadow-sm">
<div class="flex items-center gap-4">
<a
href={`/events/${page.params.event_id}/leads/exhibit/${page.params.exhibit_id}`}
class="btn btn-sm preset-outlined-surface"
>
class="btn btn-sm preset-outlined-surface">
<ChevronLeft size="1.2em" />
<span class="hidden sm:inline ml-1">Back</span>
<span class="ml-1 hidden sm:inline">Back</span>
</a>
<h1 class="text-lg font-bold">Lead Profile</h1>
</div>
@@ -103,8 +122,10 @@
class="btn btn-sm"
class:preset-filled-primary={is_edit_mode}
class:preset-outlined-surface={!is_edit_mode}
onclick={() => { is_edit_mode = !is_edit_mode; remove_status = 'idle'; }}
>
onclick={() => {
is_edit_mode = !is_edit_mode;
remove_status = 'idle';
}}>
{#if is_edit_mode}
<Eye size="1.2em" class="mr-1" /> View
{:else}
@@ -119,23 +140,21 @@
<button
type="button"
class="btn btn-sm preset-filled-error font-bold"
onclick={remove_lead}
>
onclick={remove_lead}>
<Trash2 size="1em" />
Confirm Remove?
</button>
<button
type="button"
class="btn btn-sm preset-outlined-surface opacity-60"
onclick={() => remove_status = 'idle'}
>Cancel</button>
onclick={() => (remove_status = 'idle')}
>Cancel</button>
{:else}
<button
type="button"
class="btn btn-sm preset-outlined-error opacity-70"
disabled={remove_status === 'removing'}
onclick={() => remove_status = 'confirm'}
>
onclick={() => (remove_status = 'confirm')}>
{#if remove_status === 'removing'}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
@@ -148,7 +167,8 @@
{/if}
{#if $lq__lead_obj?.priority}
<span class="badge preset-filled-warning font-bold flex items-center gap-1">
<span
class="badge preset-filled-warning flex items-center gap-1 font-bold">
<Star size="1em" fill="currentColor" />
Priority
</span>
@@ -156,94 +176,143 @@
</div>
</header>
<div class="w-full max-w-5xl p-4 sm:p-6 space-y-6">
<div class="w-full max-w-5xl space-y-6 p-4 sm:p-6">
{#if !$lq__lead_obj}
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center">
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
<div
class="flex flex-col items-center justify-center p-20 text-center opacity-50">
<LoaderCircle size="3em" class="mx-auto mb-4 animate-spin" />
<p class="text-xl">Loading lead details...</p>
</div>
{:else}
<!-- Main Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left: Profile Info -->
<div class="lg:col-span-2 space-y-6">
<div class="space-y-6 lg:col-span-2">
<!-- Attendee Core Identity -->
<div class="card p-4 preset-tonal-surface shadow-lg border-l-4 border-primary-500 space-y-2">
<div
class="card preset-tonal-surface border-primary-500 space-y-2 border-l-4 p-4 shadow-lg">
<!-- Name row: small inline icon -->
<div class="flex items-center gap-2">
<User size="1.4em" class="text-primary-500 flex-none" />
<h2 class="text-2xl font-black leading-tight">
{@html $lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'}
<User
size="1.4em"
class="text-primary-500 flex-none" />
<h2 class="text-2xl leading-tight font-black">
{@html $lq__lead_obj.event_badge_full_name ||
$lq__lead_obj.event_badge_full_name_override ||
'Unknown Attendee'}
</h2>
</div>
<!-- Key details — all visible above the fold on mobile -->
<div class="space-y-1.5 pl-1">
{#if $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}
<div class="flex items-center gap-2 text-sm opacity-80">
<Briefcase size="1em" class="flex-none opacity-60" />
<span>{@html $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}</span>
<div
class="flex items-center gap-2 text-sm opacity-80">
<Briefcase
size="1em"
class="flex-none opacity-60" />
<span
>{@html $lq__lead_obj.event_badge_professional_title ||
$lq__lead_obj.event_badge_professional_title_override}</span>
</div>
{/if}
{#if $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}
<div class="flex items-center gap-2 text-sm font-semibold text-primary-500">
<div
class="text-primary-500 flex items-center gap-2 text-sm font-semibold">
<MapPin size="1em" class="flex-none" />
<span>{@html $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}</span>
<span
>{@html $lq__lead_obj.event_badge_affiliations ||
$lq__lead_obj.event_badge_affiliations_override}</span>
</div>
{/if}
<div class="flex items-center gap-2 text-sm opacity-70">
<div
class="flex items-center gap-2 text-sm opacity-70">
<Mail size="1em" class="flex-none" />
<span class="font-mono truncate">{$lq__lead_obj.event_badge_email || 'No email on file'}</span>
<span class="truncate font-mono"
>{$lq__lead_obj.event_badge_email ||
'No email on file'}</span>
</div>
<div class="flex items-center gap-2 text-sm opacity-60">
<div
class="flex items-center gap-2 text-sm opacity-60">
<CalendarDays size="1em" class="flex-none" />
<span>Captured {format_date($lq__lead_obj.created_on)}</span>
<span
>Captured {format_date(
$lq__lead_obj.created_on
)}</span>
</div>
</div>
</div>
<!-- Custom Responses Section -->
<div class="card p-6 space-y-4 shadow-md">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3">
<div class="card space-y-4 p-6 shadow-md">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
<ListTodo size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Custom Responses / Qualifiers</h3>
<h3
class="text-lg font-bold tracking-wider uppercase">
Custom Responses / Qualifiers
</h3>
</div>
{#if is_edit_mode}
<Comp_lead_detail_form
exhibit_tracking_id={exhibit_tracking_id ?? ''}
exhibit_id={page.params.exhibit_id ?? ''}
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
current_responses_json={$lq__lead_obj.responses_json ?? '{}'}
/>
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
'[]'}
current_responses_json={$lq__lead_obj.responses_json ??
'{}'} />
{:else if $lq__lead_obj.responses_json}
{@const responses = typeof $lq__lead_obj.responses_json === 'string' ? JSON.parse($lq__lead_obj.responses_json) : $lq__lead_obj.responses_json}
{@const responses =
typeof $lq__lead_obj.responses_json === 'string'
? JSON.parse($lq__lead_obj.responses_json)
: $lq__lead_obj.responses_json}
{#if Object.keys(responses).length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 animate-in fade-in">
<div
class="animate-in fade-in grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each Object.entries(responses) as [question, answer] (question)}
{@const display_value = (answer !== null && typeof answer === 'object') ? (answer as any).response ?? '' : String(answer ?? '')}
<div class="p-3 bg-surface-500/5 rounded-lg border border-surface-500/10">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1 leading-tight">{question}</div>
<div class="font-semibold text-sm">{display_value || '—'}</div>
{@const display_value =
answer !== null &&
typeof answer === 'object'
? ((answer as any).response ??
'')
: String(answer ?? '')}
<div
class="bg-surface-500/5 border-surface-500/10 rounded-lg border p-3">
<div
class="mb-1 text-[10px] leading-tight font-black tracking-widest uppercase opacity-40">
{question}
</div>
<div class="text-sm font-semibold">
{display_value || '—'}
</div>
</div>
{/each}
</div>
{:else}
<p class="text-center opacity-30 italic py-4">No responses captured for this lead.</p>
<p class="py-4 text-center italic opacity-30">
No responses captured for this lead.
</p>
{/if}
{:else}
<p class="text-center opacity-30 italic py-4">No responses captured for this lead.</p>
<p class="py-4 text-center italic opacity-30">
No responses captured for this lead.
</p>
{/if}
</div>
<!-- Notes Section -->
<div class="card p-6 space-y-4 shadow-md">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3">
<div class="card space-y-4 p-6 shadow-md">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
<FileText size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Exhibitor Notes</h3>
<h3
class="text-lg font-bold tracking-wider uppercase">
Exhibitor Notes
</h3>
</div>
<div class="bg-surface-500/5 p-5 rounded-xl border border-surface-500/10 min-h-[120px]">
<div
class="bg-surface-500/5 border-surface-500/10 min-h-[120px] rounded-xl border p-5">
{#if is_edit_mode}
<Element_ae_obj_field_editor
object_type="event_exhibit_tracking"
@@ -252,14 +321,15 @@
field_type="tiptap"
current_value={$lq__lead_obj.exhibitor_notes}
object_reload={true}
display_block={true}
/>
display_block={true} />
{:else if $lq__lead_obj.exhibitor_notes}
<div class="prose dark:prose-invert max-w-none leading-relaxed">
<div
class="prose dark:prose-invert max-w-none leading-relaxed">
{@html $lq__lead_obj.exhibitor_notes}
</div>
{:else}
<div class="h-full flex items-center justify-center italic opacity-30 text-sm">
<div
class="flex h-full items-center justify-center text-sm italic opacity-30">
No notes have been added for this lead yet.
</div>
{/if}
@@ -270,69 +340,105 @@
<!-- Right: Metadata & Stats -->
<div class="space-y-6">
<!-- exhibit association -->
<div class="card p-5 space-y-4 shadow-md bg-surface-100-900 border border-surface-500/10">
<div class="flex items-center gap-2 text-primary-500">
<div
class="card bg-surface-100-900 border-surface-500/10 space-y-4 border p-5 shadow-md">
<div class="text-primary-500 flex items-center gap-2">
<Store size="1.2em" />
<h3 class="font-bold uppercase text-xs tracking-widest">Exhibit Context</h3>
<h3
class="text-xs font-bold tracking-widest uppercase">
Exhibit Context
</h3>
</div>
<div class="space-y-3">
{#if is_edit_mode}
<div class="flex justify-between items-center">
<span class="text-sm opacity-60">Exhibit Name</span>
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
<div class="flex items-center justify-between">
<span class="text-sm opacity-60"
>Exhibit Name</span>
<span class="font-bold"
>{$lq__lead_obj.event_exhibit_name ||
'...'}</span>
</div>
{/if}
<div class="flex justify-between items-center">
<span class="text-sm opacity-60">Captured By</span>
<span class="font-mono text-[10px]">{$lq__lead_obj.external_person_id || 'Unknown'}</span>
<div class="flex items-center justify-between">
<span class="text-sm opacity-60"
>Captured By</span>
<span class="font-mono text-[10px]"
>{$lq__lead_obj.external_person_id ||
'Unknown'}</span>
</div>
{#if is_edit_mode}
<div class="flex justify-between items-center pt-2 border-t border-surface-500/10">
<span class="text-xs opacity-60 font-bold">Priority Lead</span>
<div
class="border-surface-500/10 flex items-center justify-between border-t pt-2">
<span class="text-xs font-bold opacity-60"
>Priority Lead</span>
<Element_ae_obj_field_editor
object_type="event_exhibit_tracking"
object_id={exhibit_tracking_id ?? ''}
field_name="priority"
field_type="checkbox"
current_value={$lq__lead_obj.priority}
object_reload={true}
/>
object_reload={true} />
</div>
{/if}
</div>
</div>
<!-- System Info -->
<div class="card p-5 space-y-4 shadow-inner bg-surface-500/5 text-[10px] font-mono opacity-60">
<div class="font-black uppercase tracking-[0.2em] border-b border-surface-500/10 pb-2 mb-2">System Audit</div>
<div
class="card bg-surface-500/5 space-y-4 p-5 font-mono text-[10px] opacity-60 shadow-inner">
<div
class="border-surface-500/10 mb-2 border-b pb-2 font-black tracking-[0.2em] uppercase">
System Audit
</div>
<div class="flex flex-col gap-2">
<div><span class="opacity-50">LEAD ID:</span> {$lq__lead_obj.event_exhibit_tracking_id}</div>
<div><span class="opacity-50">BADGE ID:</span> {$lq__lead_obj.event_badge_id}</div>
<div><span class="opacity-50">PERSON ID:</span> {$lq__lead_obj.event_person_id}</div>
<div><span class="opacity-50">MODIFIED:</span> {format_date($lq__lead_obj.updated_on)}</div>
<div>
<span class="opacity-50">LEAD ID:</span>
{$lq__lead_obj.event_exhibit_tracking_id}
</div>
<div>
<span class="opacity-50">BADGE ID:</span>
{$lq__lead_obj.event_badge_id}
</div>
<div>
<span class="opacity-50">PERSON ID:</span>
{$lq__lead_obj.event_person_id}
</div>
<div>
<span class="opacity-50">MODIFIED:</span>
{format_date($lq__lead_obj.updated_on)}
</div>
</div>
</div>
<!-- Restore Lead card — only shown when lead has been removed (enable=false/0).
Removing sets enable=false rather than deleting so notes/responses are preserved. -->
{#if !$lq__lead_obj.enable}
<div class="card p-4 space-y-3 preset-tonal-error border border-error-500/50 shadow-sm">
<div
class="card preset-tonal-error border-error-500/50 space-y-3 border p-4 shadow-sm">
<div class="flex items-center gap-2">
<ShieldCheck size="1.4em" class="text-error-500 shrink-0" />
<ShieldCheck
size="1.4em"
class="text-error-500 shrink-0" />
<div>
<div class="font-bold text-sm">Lead Removed</div>
<div class="text-[10px] opacity-60 uppercase font-black">Not visible in leads list</div>
<div class="text-sm font-bold">
Lead Removed
</div>
<div
class="text-[10px] font-black uppercase opacity-60">
Not visible in leads list
</div>
</div>
</div>
<button
type="button"
class="btn btn-sm w-full preset-filled-success font-bold"
class="btn btn-sm preset-filled-success w-full font-bold"
disabled={remove_status === 'restoring'}
onclick={restore_lead}
>
onclick={restore_lead}>
{#if remove_status === 'restoring'}
<LoaderCircle size="1em" class="animate-spin" />
<LoaderCircle
size="1em"
class="animate-spin" />
Restoring...
{:else}
<RotateCcw size="1em" />
@@ -342,15 +448,14 @@
</div>
{/if}
</div>
</div>
{/if}
</div>
</section>
<style lang="postcss">
.lead-detail-view {
/* Ensure we match the theme's background */
@apply bg-transparent;
}
</style>
.lead-detail-view {
/* Ensure we match the theme's background */
@apply bg-transparent;
}
</style>

View File

@@ -1,153 +1,164 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ae_comp__lead_detail_form.svelte
* Lead Detail Form - Dynamic Custom Questions Editor.
*
* Question schema (event_exhibit.leads_custom_questions_json):
* [{ code, question, type, option_li }]
* - code: machine key — used as the property name in responses_json
* - question: human-readable label shown to the exhibitor during capture/review
* - type: 'text' | 'textarea' | 'toggle' | 'option'
* - option_li: array of choices; first element is always '' (blank default)
*
* Response storage (event_exhibit_tracking.responses_json):
* { [code]: { response: <value> } }
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
*
* Backward compat: older questions may use `label` instead of `code`/`question`,
* and older responses may store scalars directly (not wrapped in {response: ...}).
* Both are handled transparently.
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
interface Props {
exhibit_tracking_id: string;
exhibit_id: string;
custom_questions_json?: string; // From event_exhibit
current_responses_json?: string; // From event_exhibit_tracking
}
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ae_comp__lead_detail_form.svelte
* Lead Detail Form - Dynamic Custom Questions Editor.
*
* Question schema (event_exhibit.leads_custom_questions_json):
* [{ code, question, type, option_li }]
* - code: machine key — used as the property name in responses_json
* - question: human-readable label shown to the exhibitor during capture/review
* - type: 'text' | 'textarea' | 'toggle' | 'option'
* - option_li: array of choices; first element is always '' (blank default)
*
* Response storage (event_exhibit_tracking.responses_json):
* { [code]: { response: <value> } }
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
*
* Backward compat: older questions may use `label` instead of `code`/`question`,
* and older responses may store scalars directly (not wrapped in {response: ...}).
* Both are handled transparently.
*/
import { untrack } from 'svelte';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
interface Props {
exhibit_tracking_id: string;
exhibit_id: string;
custom_questions_json?: string; // From event_exhibit
current_responses_json?: string; // From event_exhibit_tracking
}
let { exhibit_tracking_id, exhibit_id, custom_questions_json = '[]', current_responses_json = '{}' }: Props = $props();
let {
exhibit_tracking_id,
exhibit_id,
custom_questions_json = '[]',
current_responses_json = '{}'
}: Props = $props();
let question_defs: any[] = $state([]);
// flat_responses: keyed by question code, stores scalar values for form binding.
// We unwrap the nested {response: value} format on load and re-wrap on save.
let flat_responses: Record<string, any> = $state({});
let status = $state('idle'); // idle, saving, success
let question_defs: any[] = $state([]);
// flat_responses: keyed by question code, stores scalar values for form binding.
// We unwrap the nested {response: value} format on load and re-wrap on save.
let flat_responses: Record<string, any> = $state({});
let status = $state('idle'); // idle, saving, success
$effect(() => {
try {
const defs = typeof custom_questions_json === 'string'
$effect(() => {
try {
const defs =
typeof custom_questions_json === 'string'
? JSON.parse(custom_questions_json || '[]')
: (custom_questions_json || []);
const raw = typeof current_responses_json === 'string'
: custom_questions_json || [];
const raw =
typeof current_responses_json === 'string'
? JSON.parse(current_responses_json || '{}')
: (current_responses_json || {});
: current_responses_json || {};
untrack(() => {
question_defs = defs;
// Flatten: unwrap {response: value} → scalar for form binding
const flat: Record<string, any> = {};
for (const [key, val] of Object.entries(raw)) {
if (val !== null && typeof val === 'object' && 'response' in (val as object)) {
flat[key] = (val as any).response ?? '';
} else {
flat[key] = val ?? ''; // legacy scalar
}
untrack(() => {
question_defs = defs;
// Flatten: unwrap {response: value} → scalar for form binding
const flat: Record<string, any> = {};
for (const [key, val] of Object.entries(raw)) {
if (
val !== null &&
typeof val === 'object' &&
'response' in (val as object)
) {
flat[key] = (val as any).response ?? '';
} else {
flat[key] = val ?? ''; // legacy scalar
}
flat_responses = flat;
});
} catch (e) {
console.error('Failed to parse questions/responses', e);
}
});
// Resolve the key for a question def (new: q.code, legacy: q.label)
function q_key(q: any): string {
return q.code || q.label || '';
}
async function handle_save() {
if (!exhibit_tracking_id) return;
status = 'saving';
try {
// Re-wrap scalar values back to {response: value} format before saving
const nested: Record<string, any> = {};
for (const [key, val] of Object.entries(flat_responses)) {
nested[key] = { response: val };
}
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
exhibit_tracking_id: exhibit_tracking_id,
data: {
responses_json: JSON.stringify(nested)
}
});
status = 'success';
setTimeout(() => status = 'idle', 2000);
} catch (e) {
console.error('Failed to update responses', e);
status = 'idle';
}
flat_responses = flat;
});
} catch (e) {
console.error('Failed to parse questions/responses', e);
}
});
// Resolve the key for a question def (new: q.code, legacy: q.label)
function q_key(q: any): string {
return q.code || q.label || '';
}
async function handle_save() {
if (!exhibit_tracking_id) return;
status = 'saving';
try {
// Re-wrap scalar values back to {response: value} format before saving
const nested: Record<string, any> = {};
for (const [key, val] of Object.entries(flat_responses)) {
nested[key] = { response: val };
}
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
exhibit_tracking_id: exhibit_tracking_id,
data: {
responses_json: JSON.stringify(nested)
}
});
status = 'success';
setTimeout(() => (status = 'idle'), 2000);
} catch (e) {
console.error('Failed to update responses', e);
status = 'idle';
}
}
</script>
<div class="lead-detail-form space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
{#each question_defs as q (q_key(q))}
{@const key = q_key(q)}
{@const display = q.question || q.label || key}
<div class="space-y-2">
<label class="label">
<span class="text-[10px] uppercase font-black opacity-40 tracking-widest ml-1">{display}</span>
<span
class="ml-1 text-[10px] font-black tracking-widest uppercase opacity-40"
>{display}</span>
{#if q.type === 'textarea'}
<textarea
bind:value={flat_responses[key]}
class="textarea rounded-lg p-3 text-sm"
rows="3"
placeholder="Type response..."
></textarea>
placeholder="Type response..."></textarea>
{:else if q.type === 'toggle'}
<div class="flex items-center gap-4 p-3 preset-tonal-surface rounded-lg">
<div
class="preset-tonal-surface flex items-center gap-4 rounded-lg p-3">
<input
type="checkbox"
bind:checked={flat_responses[key]}
class="checkbox"
/>
<span class="text-sm font-bold">{flat_responses[key] ? 'Yes' : 'No'}</span>
class="checkbox" />
<span class="text-sm font-bold"
>{flat_responses[key] ? 'Yes' : 'No'}</span>
</div>
{:else if q.type === 'option' || q.type === 'select'}
<!-- type 'option' is the current schema; 'select' is legacy compat -->
<select
bind:value={flat_responses[key]}
class="select rounded-lg p-3 text-sm"
>
class="select rounded-lg p-3 text-sm">
{#if Array.isArray(q.option_li)}
{#each q.option_li as opt (opt)}
<option value={opt}>{opt || '-- Select --'}</option>
<option value={opt}
>{opt || '-- Select --'}</option>
{/each}
{:else}
<!-- Legacy: options was a comma-separated string -->
<option value="">-- Select Option --</option>
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt (opt)}
{#each (q.options || '')
.split(',')
.map((o: string) => o.trim()) as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
{/if}
</select>
{:else}
<input
type="text"
bind:value={flat_responses[key]}
class="input rounded-lg p-3 text-sm"
placeholder="Type response..."
/>
placeholder="Type response..." />
{/if}
</label>
</div>
@@ -155,16 +166,17 @@
</div>
{#if question_defs.length === 0}
<p class="text-center opacity-30 italic py-4">No custom questions configured for this exhibit.</p>
<p class="py-4 text-center italic opacity-30">
No custom questions configured for this exhibit.
</p>
{/if}
<button
class="btn preset-filled-primary w-full font-bold shadow-lg"
disabled={status === 'saving'}
onclick={handle_save}
>
onclick={handle_save}>
{#if status === 'saving'}
<LoaderCircle size="1.2em" class="animate-spin mr-2" /> Saving...
<LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
{:else if status === 'success'}
<CircleCheck size="1.2em" class="mr-2" /> Saved!
{:else}